feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
# 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.
|
|
|
|
|
|
|
docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it.
Nothing 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: encodeProposal returns the bare event object,
joinPayload of one value is that value, and every plural branch in the
transcript is only taken above one. The doc now tabulates that rather than
asserting it in prose, since it is the claim the whole rollout rests on.
The one rollout constraint stands: before a caller batches, the group has to be
on a build that understands array proposals. There is no negotiation for it and
adding one is not worth it -- an old device refuses an array proposal outright,
so the failure mode is a batch that never reaches threshold and is abandoned,
visible in the transcript and costing a retry.
Also records what is left, which is nothing in the protocol: deciding what to
batch is a product question, bounded only by "a batch is only as available as
its worst item" and "GroupKeyStateManager.propose must never batch".
The phases are kept as written rather than rewritten into a description of the
result -- the code reads better against the argument it came from -- with the
two places the implementation chose differently (itemIndex over index,
DROP COLUMN over a table rebuild) marked in their own sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
|
|
|
|
**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
|
feat: sign a chapter and every chunk of it in one session
A chapter proposal now carries the chapter and a chunk per paragraph, and the
group signs the lot at once. Every row a member ends up with is signed: a
translation is of a chunk, and a chunk that carries the group's signature over
its own words can be checked by anybody holding it, rather than only by
re-deriving it from the chapter it came out of.
This replaces the derivation two commits ago, which split the chunks out of the
signed chapter's text on each device and left them as rumors. That was the
right shape when a chunk could only have its own signature by having its own
quorum. Batch signing removed that, and this is the other side of the trade
`MantraChunk.chunksOf` was weighed against.
**A batch whose items name each other.** A chunk carries its chapter's id, and
that id is a hash over the group's key at the room's derivation path -- neither
resolved until the proposal runs. A caller computing it would be recomputing
`signingPath`, the one input in this protocol that must never come from a
proposer, since the path decides which key the group signs as. So
`proposeSigningBatch` gains a second form: a `lead` template, and a
`dependents` builder handed the lead *after* it is authored, returning the
events that reference it. Every id still comes out of `unsignedEventOf`, which
makes an item naming a chapter nobody signed something that cannot be built
rather than something to be tested for. `AddChapterViewModel` passes
`ChunkEvent::splitOf` and nothing else.
The lead is item 0. Items apply in `itemIndex` order and a chunk row whose
chapter does not exist yet is a foreign key violation, so what is referenced is
signed first as well as named first.
**The cost, in front of whoever is typing.** `MAX_BATCH_SIZE` is 64 and the
chapter takes one place, so a chapter is capped at 63 paragraphs and a longer
one has to be split in two. That is a real limit on real prose. The form counts
chunks against the cap as the text is typed, colours the count when it is past,
says what to do about it, and will not propose -- because the alternative is an
IllegalArgumentException after the fact. The manager still refuses
independently; the screen is not what enforces it.
**What went away.** `MantraChunk.chunksOf` and the derivation it did inside
`ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and
go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent`
still carries the whole text beside chunks that hold the same words: chunk
boundaries are a decision about how to divide the work, and a chapter that kept
only the pieces could never be divided differently again.
**Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what
each chunk names, counts and carries. `SignedChapterTest` signs a real batch,
one FROST instance per item, and checks every chunk row is authored by the room
and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the
real proposal against a real database, which is where the sharp edge is: item
order, the chunks naming the chapter as the group will author it, and both ends
of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session
behind. Checked against broken implementations: putting the lead last, naming
the wrong chapter, and stamping the chunks off the clock are each caught, in
both suites.
`jvmTest` runs on linux again as of the merge, which is what made the
database-backed test possible.
Dropped a nonce-reuse test that was in the first draft of this: it asserted
over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest`
already hold the manager to giving every item its own nonce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:34:12 +02:00
|
|
|
|
`FrostSigningRepository.proposeSigningBatch` are the entry points. Adding a
|
|
|
|
|
|
chapter is the first caller, and needed a second form of them; see
|
|
|
|
|
|
[The first caller](#the-first-caller-a-chapter-and-its-chunks).
|
docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it.
Nothing 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: encodeProposal returns the bare event object,
joinPayload of one value is that value, and every plural branch in the
transcript is only taken above one. The doc now tabulates that rather than
asserting it in prose, since it is the claim the whole rollout rests on.
The one rollout constraint stands: before a caller batches, the group has to be
on a build that understands array proposals. There is no negotiation for it and
adding one is not worth it -- an old device refuses an array proposal outright,
so the failure mode is a batch that never reaches threshold and is abandoned,
visible in the transcript and costing a retry.
Also records what is left, which is nothing in the protocol: deciding what to
batch is a product question, bounded only by "a batch is only as available as
its worst item" and "GroupKeyStateManager.propose must never batch".
The phases are kept as written rather than rewritten into a description of the
result -- the code reads better against the argument it came from -- with the
two places the implementation chose differently (itemIndex over index,
DROP COLUMN over a table rebuild) marked in their own sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
|
|
|
|
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt)
|
|
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt)
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
@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.
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt),
|
|
|
|
|
|
regenerate `composeApp/schemas/10.json`, and add the DAO methods:
|
|
|
|
|
|
`getItems(sessionId)`, `observeItems(sessionId)`, `upsertItems(List<FrostSigningItem>)`,
|
|
|
|
|
|
`countItems(sessionId)`, `countSignedItems(sessionId)`.
|
|
|
|
|
|
|
|
|
|
|
|
**Test:** extend
|
|
|
|
|
|
[FrostSigningSessionDaoJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt)
|
|
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt):
|
|
|
|
|
|
|
|
|
|
|
|
1. Load `items` once, ordered by `index`.
|
|
|
|
|
|
2. Nonce generation becomes a `map` over items, each with its own
|
|
|
|
|
|
`session.nonceRandom` → `item.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.create`s and 64 signs on every
|
|
|
|
|
|
message the group sends — several hundred native calls to discover there is
|
|
|
|
|
|
nothing to do.
|
|
|
|
|
|
|
refactor(frost): run a signing session as k FROST instances in lockstep
Phase 2 of docs/frost-batch-signing.md. Pure refactor: proposals still carry
one event, the wire is byte-identical, and every test passes unchanged --
344 jvmTest and 217 testDebugUnitTest, none of them edited in this commit.
advance() now loops over FrostSigningItem rows rather than reading the first
one. One nonce per item, one aggregate per item, one Session.create per item,
one partial signature per item, one signature per item. The signer set, the
public shares, the tweak cache and the approval stay shared, because they are
the terms that do not enter e = H(R‖P‖m).
The coordinator's aggregation is the place where that distinction bites: it
builds one AggregatedNonce per item, each from that item's nonce from each
chosen signer. Reusing one across two items would be reusing R across two
messages.
## The payload codec, early
joinPayload/splitPayload land here rather than with the wire change, because at
a batch of one a comma join is the identity -- the payload is the bare value it
has always been. That leaves Phase 3 to the proposal encoding alone.
splitPayload is strict: a payload that is not exactly the batch's length is
dropped rather than truncated or padded. It runs in orderedNonces,
orderedPartialSignatures and splitForSession -- never in record(), which stores
payloads without parsing them so that a nonce can arrive before the proposal
that would give it a length to check against.
## Two short-circuits, and one trap in the first
advance() runs on every arriving message, so at a batch of k it was k native
key generations, k Session.creates and k signs each time, usually to discover
there was nothing left to do.
- Nonces are generated by `lazy`. The obvious version -- a guard computing
`ownNonce == null || (isSigner() && ownPartial == null)` -- is wrong, and
wrong in a way that reads fine and fails every signing test: the coordinator
settles the signer set further down the same pass, so isSigner() at the top
is false on exactly the pass where the coordinator goes on to sign, and the
nonces are never generated. Reproduced as IndexOutOfBounds before switching
to lazy, which has no prediction to make.
- A device that is neither signing nor aggregating leaves before building any
FROST session, rather than building k of them to do nothing with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:40:22 +02:00
|
|
|
|
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.
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
|
|
|
|
|
|
**Test:** existing tests are the test. If
|
|
|
|
|
|
[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt)
|
|
|
|
|
|
and
|
|
|
|
|
|
[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
refactor(frost): run a signing session as k FROST instances in lockstep
Phase 2 of docs/frost-batch-signing.md. Pure refactor: proposals still carry
one event, the wire is byte-identical, and every test passes unchanged --
344 jvmTest and 217 testDebugUnitTest, none of them edited in this commit.
advance() now loops over FrostSigningItem rows rather than reading the first
one. One nonce per item, one aggregate per item, one Session.create per item,
one partial signature per item, one signature per item. The signer set, the
public shares, the tweak cache and the approval stay shared, because they are
the terms that do not enter e = H(R‖P‖m).
The coordinator's aggregation is the place where that distinction bites: it
builds one AggregatedNonce per item, each from that item's nonce from each
chosen signer. Reusing one across two items would be reusing R across two
messages.
## The payload codec, early
joinPayload/splitPayload land here rather than with the wire change, because at
a batch of one a comma join is the identity -- the payload is the bare value it
has always been. That leaves Phase 3 to the proposal encoding alone.
splitPayload is strict: a payload that is not exactly the batch's length is
dropped rather than truncated or padded. It runs in orderedNonces,
orderedPartialSignatures and splitForSession -- never in record(), which stores
payloads without parsing them so that a nonce can arrive before the proposal
that would give it a length to check against.
## Two short-circuits, and one trap in the first
advance() runs on every arriving message, so at a batch of k it was k native
key generations, k Session.creates and k signs each time, usually to discover
there was nothing left to do.
- Nonces are generated by `lazy`. The obvious version -- a guard computing
`ownNonce == null || (isSigner() && ownPartial == null)` -- is wrong, and
wrong in a way that reads fine and fails every signing test: the coordinator
settles the signer set further down the same pass, so isSigner() at the top
is false on exactly the pass where the coordinator goes on to sign, and the
nonces are never generated. Reproduced as IndexOutOfBounds before switching
to lazy, which has no prediction to make.
- A device that is neither signing nor aggregating leaves before building any
FROST session, rather than building k of them to do nothing with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:40:22 +02:00
|
|
|
|
*(Landed in Phase 2 — see above. Restated here because the rest of this phase
|
|
|
|
|
|
depends on it.)*
|
|
|
|
|
|
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
`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:
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
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
|
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>
2026-09-06 04:48:24 +02:00
|
|
|
|
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.
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 4 — the batch API and its call sites
|
|
|
|
|
|
|
|
|
|
|
|
**A day.**
|
|
|
|
|
|
|
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>
2026-09-06 04:48:24 +02:00
|
|
|
|
*(`FrostSigningManager.proposeSigningBatch` itself landed in Phase 3 — see
|
|
|
|
|
|
above. This phase is what surrounds it.)*
|
|
|
|
|
|
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
```kotlin
|
|
|
|
|
|
suspend fun proposeSigningBatch(
|
|
|
|
|
|
database: MantraDatabase,
|
|
|
|
|
|
localChatRoom: LocalChatRoom,
|
|
|
|
|
|
userPublicKey: HexKey,
|
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>
2026-09-06 04:48:24 +02:00
|
|
|
|
events: List<EventTemplate<*>>, // each carries its own createdAt
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
key: DkgSession? = null,
|
|
|
|
|
|
): FrostSigningSession
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`proposeSigning` stays, as the one-element call into it, so
|
|
|
|
|
|
[AddDialectViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt),
|
|
|
|
|
|
[AddArtifactViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt)
|
|
|
|
|
|
and
|
|
|
|
|
|
[GroupKeyStateManager.propose](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt)
|
|
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt),
|
|
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt)
|
|
|
|
|
|
combines `observeItems` into its existing `combine`; nothing else changes,
|
|
|
|
|
|
since it already re-renders on every session write.
|
|
|
|
|
|
- [FrostSigningScreen](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt)
|
|
|
|
|
|
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](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt)
|
|
|
|
|
|
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.**
|
|
|
|
|
|
|
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>
2026-09-06 04:48:24 +02:00
|
|
|
|
*(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.)*
|
|
|
|
|
|
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
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
|
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>
2026-09-06 04:48:24 +02:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-09-06 04:58:48 +02:00
|
|
|
|
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](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt),
|
|
|
|
|
|
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).
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 7 — rollout
|
|
|
|
|
|
|
|
|
|
|
|
**No code.**
|
|
|
|
|
|
|
docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it.
Nothing 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: encodeProposal returns the bare event object,
joinPayload of one value is that value, and every plural branch in the
transcript is only taken above one. The doc now tabulates that rather than
asserting it in prose, since it is the claim the whole rollout rests on.
The one rollout constraint stands: before a caller batches, the group has to be
on a build that understands array proposals. There is no negotiation for it and
adding one is not worth it -- an old device refuses an array proposal outright,
so the failure mode is a batch that never reaches threshold and is abandoned,
visible in the transcript and costing a retry.
Also records what is left, which is nothing in the protocol: deciding what to
batch is a product question, bounded only by "a batch is only as available as
its worst item" and "GroupKeyStateManager.propose must never batch".
The phases are kept as written rather than rewritten into a description of the
result -- the code reads better against the argument it came from -- with the
two places the implementation chose differently (itemIndex over index,
DROP COLUMN over a table rebuild) marked in their own sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
|
|
|
|
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.
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it.
Nothing 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: encodeProposal returns the bare event object,
joinPayload of one value is that value, and every plural branch in the
transcript is only taken above one. The doc now tabulates that rather than
asserting it in prose, since it is the claim the whole rollout rests on.
The one rollout constraint stands: before a caller batches, the group has to be
on a build that understands array proposals. There is no negotiation for it and
adding one is not worth it -- an old device refuses an array proposal outright,
so the failure mode is a batch that never reaches threshold and is abandoned,
visible in the transcript and costing a retry.
Also records what is left, which is nothing in the protocol: deciding what to
batch is a product question, bounded only by "a batch is only as available as
its worst item" and "GroupKeyStateManager.propose must never batch".
The phases are kept as written rather than rewritten into a description of the
result -- the code reads better against the argument it came from -- with the
two places the implementation chose differently (itemIndex over index,
DROP COLUMN over a table rebuild) marked in their own sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
|
|
|
|
not worth it: the failure mode is a batch that never reaches threshold and is
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
abandoned, which is visible in the transcript and costs nothing but a retry.
|
|
|
|
|
|
|
docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it.
Nothing 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: encodeProposal returns the bare event object,
joinPayload of one value is that value, and every plural branch in the
transcript is only taken above one. The doc now tabulates that rather than
asserting it in prose, since it is the claim the whole rollout rests on.
The one rollout constraint stands: before a caller batches, the group has to be
on a build that understands array proposals. There is no negotiation for it and
adding one is not worth it -- an old device refuses an array proposal outright,
so the failure mode is a batch that never reaches threshold and is abandoned,
visible in the transcript and costing a retry.
Also records what is left, which is nothing in the protocol: deciding what to
batch is a product question, bounded only by "a batch is only as available as
its worst item" and "GroupKeyStateManager.propose must never batch".
The phases are kept as written rather than rewritten into a description of the
result -- the code reads better against the argument it came from -- with the
two places the implementation chose differently (itemIndex over index,
DROP COLUMN over a table rebuild) marked in their own sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
|
|
|
|
### 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.
|
|
|
|
|
|
|
feat: sign a chapter and every chunk of it in one session
A chapter proposal now carries the chapter and a chunk per paragraph, and the
group signs the lot at once. Every row a member ends up with is signed: a
translation is of a chunk, and a chunk that carries the group's signature over
its own words can be checked by anybody holding it, rather than only by
re-deriving it from the chapter it came out of.
This replaces the derivation two commits ago, which split the chunks out of the
signed chapter's text on each device and left them as rumors. That was the
right shape when a chunk could only have its own signature by having its own
quorum. Batch signing removed that, and this is the other side of the trade
`MantraChunk.chunksOf` was weighed against.
**A batch whose items name each other.** A chunk carries its chapter's id, and
that id is a hash over the group's key at the room's derivation path -- neither
resolved until the proposal runs. A caller computing it would be recomputing
`signingPath`, the one input in this protocol that must never come from a
proposer, since the path decides which key the group signs as. So
`proposeSigningBatch` gains a second form: a `lead` template, and a
`dependents` builder handed the lead *after* it is authored, returning the
events that reference it. Every id still comes out of `unsignedEventOf`, which
makes an item naming a chapter nobody signed something that cannot be built
rather than something to be tested for. `AddChapterViewModel` passes
`ChunkEvent::splitOf` and nothing else.
The lead is item 0. Items apply in `itemIndex` order and a chunk row whose
chapter does not exist yet is a foreign key violation, so what is referenced is
signed first as well as named first.
**The cost, in front of whoever is typing.** `MAX_BATCH_SIZE` is 64 and the
chapter takes one place, so a chapter is capped at 63 paragraphs and a longer
one has to be split in two. That is a real limit on real prose. The form counts
chunks against the cap as the text is typed, colours the count when it is past,
says what to do about it, and will not propose -- because the alternative is an
IllegalArgumentException after the fact. The manager still refuses
independently; the screen is not what enforces it.
**What went away.** `MantraChunk.chunksOf` and the derivation it did inside
`ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and
go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent`
still carries the whole text beside chunks that hold the same words: chunk
boundaries are a decision about how to divide the work, and a chapter that kept
only the pieces could never be divided differently again.
**Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what
each chunk names, counts and carries. `SignedChapterTest` signs a real batch,
one FROST instance per item, and checks every chunk row is authored by the room
and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the
real proposal against a real database, which is where the sharp edge is: item
order, the chunks naming the chapter as the group will author it, and both ends
of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session
behind. Checked against broken implementations: putting the lead last, naming
the wrong chapter, and stamping the chunks off the clock are each caught, in
both suites.
`jvmTest` runs on linux again as of the merge, which is what made the
database-backed test possible.
Dropped a nonce-reuse test that was in the first draft of this: it asserted
over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest`
already hold the manager to giving every item its own nonce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:34:12 +02:00
|
|
|
|
### The first caller: a chapter and its chunks
|
|
|
|
|
|
|
|
|
|
|
|
Adding a chapter is the first thing in the app to batch. A chapter is proposed
|
|
|
|
|
|
together with a chunk per paragraph — before this work, one quorum each, which
|
|
|
|
|
|
is why the chunks were briefly derived on arrival from the signed chapter's text
|
|
|
|
|
|
instead. They are signed now, and each carries the group's signature over its own
|
|
|
|
|
|
words: a translation is of a chunk, and a chunk that can be checked on its own is
|
|
|
|
|
|
worth more than one that can only be checked by re-deriving it.
|
|
|
|
|
|
|
|
|
|
|
|
Two things it needed that a flat list could not give:
|
|
|
|
|
|
|
|
|
|
|
|
**An item that names another item.** A chunk carries the id of its chapter, and
|
|
|
|
|
|
that id is a hash over the group's key at the room's derivation path — neither
|
|
|
|
|
|
resolved until the proposal is made. A caller computing it would be recomputing
|
|
|
|
|
|
`signingPath`, the one input that must never come from a proposer. So
|
|
|
|
|
|
`proposeSigningBatch` has a second form taking a `lead` template and a
|
|
|
|
|
|
`dependents` builder, which is handed the lead *after* it is authored and returns
|
|
|
|
|
|
the events referring to it. Every id still comes from `unsignedEventOf`, and an
|
|
|
|
|
|
item naming a chapter nobody signed stops being a mistake that can be made.
|
|
|
|
|
|
|
|
|
|
|
|
**The lead is item 0.** Items apply in `itemIndex` order, and a chunk row whose
|
|
|
|
|
|
chapter does not exist yet is a foreign key violation, so the thing being
|
|
|
|
|
|
referenced has to be signed first in the batch as well as named first.
|
|
|
|
|
|
|
|
|
|
|
|
**What it costs, in front of the user.** `MAX_BATCH_SIZE` is 64 and the chapter
|
|
|
|
|
|
takes one place, so a chapter is capped at 63 paragraphs and a longer one has to
|
|
|
|
|
|
be split in two. That is a real limit on real prose. `AddChapterScreen` shows the
|
|
|
|
|
|
chunk count against the cap as the text is typed and refuses to propose past it,
|
|
|
|
|
|
because the alternative is an `IllegalArgumentException` after the fact.
|
|
|
|
|
|
|
|
|
|
|
|
The general rule this leaves behind: **a batch is for events that arrive
|
|
|
|
|
|
together and are checked apart.** If the items are only ever read through one of
|
|
|
|
|
|
them, deriving is cheaper and has no cap; if each is something a member might
|
|
|
|
|
|
hold, hand it its own signature.
|
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
|
|
|
|
|
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.
## What moved, and why it had to
A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.
So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.
itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.
No itemCount column. The count is a COUNT(*), 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 rows.
## Migration 9 -> 10
Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.
Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.
## Invariants established here for the phases that follow
- signerIds and every item's aggregatedNonce are one write-once unit, applied
by applyAggregate() -- items first in one transaction, then the session, so
"some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
reading a flag.
- complete() verifies every signature before applying any event, so a batch is
all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. 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 a single nonce.
signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.
## Tests
- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
database, asserting the seed and aggregate values survive -- not merely that
a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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](./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.
|