9ac4bcbee39b93777c35c55d2f9bc1fe04fba52f
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
59c34263b3 |
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> |
||
|
|
935a8fe37a |
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> |
||
|
|
dff41d417d |
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> |