451 lines
21 KiB
Markdown
451 lines
21 KiB
Markdown
|
|
# 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.
|
|||
|
|
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
Fix it in this phase, while it is still cheap to verify: short-circuit the
|
|||
|
|
regenerate-and-sign block when this device has already published both its nonce
|
|||
|
|
and its partial-signature messages, and skip `Session.create` for a device that
|
|||
|
|
is not in the signer set and is not the coordinator. Both are pure
|
|||
|
|
optimisations at k=1, which is the point of doing them before k>1 exists.
|
|||
|
|
|
|||
|
|
**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
|
|||
|
|
|
|||
|
|
`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
|
|||
|
|
term that actually binds.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 4 — the batch API and its call sites
|
|||
|
|
|
|||
|
|
**A day.**
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
suspend fun proposeSigningBatch(
|
|||
|
|
database: MantraDatabase,
|
|||
|
|
localChatRoom: LocalChatRoom,
|
|||
|
|
userPublicKey: HexKey,
|
|||
|
|
events: List<EventTemplate>, // kind, tags, content
|
|||
|
|
key: DkgSession? = null,
|
|||
|
|
createdAt: Long = Clock.System.now().epochSeconds
|
|||
|
|
): 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.**
|
|||
|
|
|
|||
|
|
Extend
|
|||
|
|
[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt),
|
|||
|
|
which already runs a real signing session between two devices over two
|
|||
|
|
databases, with a `k = 3` batch: both devices reach `COMPLETE`, all three
|
|||
|
|
signatures verify against their own event ids under the room's key, and all
|
|||
|
|
three events are applied locally on both.
|
|||
|
|
|
|||
|
|
Then in
|
|||
|
|
[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt),
|
|||
|
|
against real FROST and no database:
|
|||
|
|
|
|||
|
|
- a `k = 3` batch producing three signatures nostr accepts, from one signer set;
|
|||
|
|
- **the negative one that matters** — assert the three aggregated nonces are
|
|||
|
|
pairwise distinct, and that the three seeds are. It is the cheapest possible
|
|||
|
|
guard against the one mistake in this document that loses the key, and it will
|
|||
|
|
catch an index bug that every positive test still passes;
|
|||
|
|
- a wrong-length payload is dropped rather than truncated;
|
|||
|
|
- a second proposal under the same session id with a changed item is ignored.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 7 — rollout
|
|||
|
|
|
|||
|
|
**No code.**
|
|||
|
|
|
|||
|
|
Nothing here needs a feature flag. `k = 1` is the entire behaviour of the app
|
|||
|
|
after Phase 4, byte-identical on the wire to the app before Phase 1, and no
|
|||
|
|
caller batches anything until one is written to. Phases 1 and 2 are shippable on
|
|||
|
|
their own and worth shipping on their own — they are a schema move and a
|
|||
|
|
refactor, and landing them apart from the wire change means a bisect over a
|
|||
|
|
signing bug lands on one or the other rather than on all of it.
|
|||
|
|
|
|||
|
|
Before the first caller batches, confirm the group is on a build that
|
|||
|
|
understands array proposals. There is no negotiation for this and adding one is
|
|||
|
|
not worth it; the failure mode is a batch that never reaches threshold and is
|
|||
|
|
abandoned, which is visible in the transcript and costs nothing but a retry.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 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.
|