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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,7 @@ import press.mantra.compose.database.model.RecentSearch
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.Relay
|
||||
import press.mantra.compose.database.model.RepostedRelation
|
||||
@@ -130,6 +131,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
DkgParticipantMessage::class,
|
||||
DkgSession::class,
|
||||
FrostSignerMessage::class,
|
||||
FrostSigningItem::class,
|
||||
FrostSigningSession::class,
|
||||
GiftWrapMessage::class,
|
||||
GiftWrapSeal::class,
|
||||
@@ -172,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 9,
|
||||
version = 10,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -215,6 +217,11 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// is what they were signing as, so one caught mid-flight finishes the way
|
||||
// it began rather than switching keys between two of its own rounds.
|
||||
AutoMigration(from = 8, to = 9)
|
||||
// v10 moves FrostSigningSession's five per-event columns onto the new
|
||||
// FrostSigningItem table, so one session can sign a batch. It copies
|
||||
// before it drops, which no AutoMigration can express -- see
|
||||
// MIGRATION_9_10, and the note there on why an in-flight session losing
|
||||
// its nonce seed would be worse than losing the session.
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
@@ -3,6 +3,7 @@ package press.mantra.compose.database.builder
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import press.mantra.compose.database.migrations.MIGRATION_3_4
|
||||
import press.mantra.compose.database.migrations.MIGRATION_9_10
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
|
||||
@@ -16,9 +17,11 @@ fun getRoomDatabase(
|
||||
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
|
||||
): press.mantra.compose.database.MantraDatabase {
|
||||
return builder
|
||||
// Everything else Room generates itself; this one rewrites rows rather than
|
||||
// changing shape, which an AutoMigration cannot express.
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
// Everything else Room generates itself. These two move data rather than
|
||||
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
|
||||
// chat rows, and 9->10 copies a session's per-event columns onto the items
|
||||
// table before dropping them.
|
||||
.addMigrations(MIGRATION_3_4, MIGRATION_9_10)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.build()
|
||||
|
||||
@@ -2,11 +2,13 @@ package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
|
||||
@Dao
|
||||
@@ -36,6 +38,46 @@ interface FrostSigningSessionDao {
|
||||
@Upsert
|
||||
suspend fun upsert(frostSignerMessage: FrostSignerMessage)
|
||||
|
||||
/**
|
||||
* A session's events, in the order the batch fixed at proposal.
|
||||
*
|
||||
* Every join of nonces and partial signatures is positional, so this
|
||||
* ordering is part of the protocol rather than a presentation choice —
|
||||
* two devices reading a batch in different orders aggregate against
|
||||
* different messages.
|
||||
*/
|
||||
@Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC")
|
||||
suspend fun getItems(sessionId: String): List<FrostSigningItem>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC")
|
||||
fun observeItems(sessionId: String): Flow<List<FrostSigningItem>>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId AND itemIndex = :itemIndex")
|
||||
suspend fun getItem(sessionId: String, itemIndex: Int): FrostSigningItem?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(frostSigningItem: FrostSigningItem)
|
||||
|
||||
/**
|
||||
* Writes a whole batch's worth of items at once.
|
||||
*
|
||||
* A transaction rather than a loop because the coordinator's two broadcasts
|
||||
* each settle every item together: a half-written aggregate would leave the
|
||||
* session gated on `signerIds` while some items had no nonce to aggregate
|
||||
* against, which is a stall no message can clear.
|
||||
*/
|
||||
@Transaction
|
||||
@Upsert
|
||||
suspend fun upsertItems(items: List<FrostSigningItem>)
|
||||
|
||||
/** How many events this session signs. Derived rather than stored — see [FrostSigningItem]. */
|
||||
@Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId")
|
||||
suspend fun countItems(sessionId: String): Int
|
||||
|
||||
/** How many of them the group has finished. Equal to [countItems] once it is done. */
|
||||
@Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId AND signature IS NOT NULL")
|
||||
suspend fun countSignedItems(sessionId: String): Int
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC")
|
||||
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<FrostSignerMessage>
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
/**
|
||||
* Moves a signing session's per-event columns onto `FrostSigningItem`, so a
|
||||
* session can sign more than one event.
|
||||
*
|
||||
* Not an `AutoMigration`. Room can create the table and it can drop the columns,
|
||||
* but it cannot copy between them, and this migration is entirely about the copy.
|
||||
*
|
||||
* ### Why the backfill has to be exact
|
||||
*
|
||||
* A session in flight at upgrade time holds two things that cannot be
|
||||
* regenerated: `nonceRandom`, the seed its secret nonce is derived from, and
|
||||
* `aggregatedNonce`, the aggregate its partial signature is made against. Lose
|
||||
* the seed and the next pass derives a *different* nonce for the same message,
|
||||
* then publishes a second partial signature over it -- two partial signatures,
|
||||
* one message, two nonces, which is precisely how a secret share is extracted.
|
||||
* Lose the aggregate and the session republishes against a new one, with the
|
||||
* same result.
|
||||
*
|
||||
* So this copies them verbatim into item 0, and an in-flight session resumes as
|
||||
* though nothing happened. Dropping the columns and letting sessions start over
|
||||
* would have been the one dangerous way to write this migration.
|
||||
*
|
||||
* `signerIds` stays on the session: it is shared by every item of a batch, and
|
||||
* an upgraded session keeps whichever set it had chosen.
|
||||
*
|
||||
* ### Why `DROP COLUMN` rather than a table rebuild
|
||||
*
|
||||
* The usual way to remove columns in SQLite -- 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 this migration
|
||||
* had just written. It would depend entirely on Room having turned foreign keys
|
||||
* off around the migration, which is not a thing worth depending on when there
|
||||
* is an alternative that cannot go wrong.
|
||||
*
|
||||
* `ALTER TABLE ... DROP COLUMN` is that alternative. It needs SQLite 3.35, and
|
||||
* the columns must be free of indices and constraints -- these five are, and
|
||||
* `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version
|
||||
* is ours rather than the host's. The table, its foreign key and both its
|
||||
* indices are left alone.
|
||||
*/
|
||||
val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `FrostSigningItem` (" +
|
||||
"`sessionId` TEXT NOT NULL, " +
|
||||
"`itemIndex` INTEGER NOT NULL, " +
|
||||
"`unsignedEventJson` TEXT NOT NULL, " +
|
||||
"`eventId` TEXT NOT NULL, " +
|
||||
"`nonceRandom` TEXT NOT NULL, " +
|
||||
"`aggregatedNonce` TEXT, " +
|
||||
"`signature` TEXT, " +
|
||||
"PRIMARY KEY(`sessionId`, `itemIndex`), " +
|
||||
"FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` " +
|
||||
"ON `FrostSigningItem` (`sessionId`)"
|
||||
)
|
||||
|
||||
// Every existing session signed exactly one event, so every one of them
|
||||
// becomes a batch of one at index 0.
|
||||
connection.execSQL(
|
||||
"INSERT INTO `FrostSigningItem` " +
|
||||
"(`sessionId`, `itemIndex`, `unsignedEventJson`, `eventId`, " +
|
||||
"`nonceRandom`, `aggregatedNonce`, `signature`) " +
|
||||
"SELECT `id`, 0, `unsignedEventJson`, `eventId`, " +
|
||||
"`nonceRandom`, `aggregatedNonce`, `signature` " +
|
||||
"FROM `FrostSigningSession`"
|
||||
)
|
||||
|
||||
listOf(
|
||||
"unsignedEventJson",
|
||||
"eventId",
|
||||
"nonceRandom",
|
||||
"aggregatedNonce",
|
||||
"signature",
|
||||
).forEach { column ->
|
||||
connection.execSQL("ALTER TABLE `FrostSigningSession` DROP COLUMN `$column`")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* One event a signing session signs, and everything about it that cannot be
|
||||
* shared with the others.
|
||||
*
|
||||
* A session signs a *batch*: one ceremony, one signer set, one approval, and `n`
|
||||
* events. What separates them is forced by FROST 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. Every item therefore carries its own nonce material and its own result,
|
||||
* and the only things a batch may hold in common are the ones that do not enter
|
||||
* that equation -- see [FrostSigningSession].
|
||||
*
|
||||
* These five columns lived on [FrostSigningSession] until the schema moved to
|
||||
* v10, which is why a session created before it reads back as exactly one item
|
||||
* at [itemIndex] 0.
|
||||
*/
|
||||
@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, from zero.
|
||||
*
|
||||
* Load-bearing rather than cosmetic: it is the order every device joins
|
||||
* nonces and partial signatures in, so two devices that disagree about it
|
||||
* aggregate against different messages and produce a signature nobody can
|
||||
* verify. Fixed by the proposal and never re-sorted.
|
||||
*
|
||||
* Named `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.
|
||||
*/
|
||||
val itemIndex: Int,
|
||||
|
||||
/**
|
||||
* The unsigned event, as JSON. Kept whole so a member can be shown what
|
||||
* they are being asked to sign rather than a hash of it.
|
||||
*/
|
||||
val unsignedEventJson: String,
|
||||
|
||||
/**
|
||||
* The event id, which is the 32 bytes actually signed.
|
||||
*
|
||||
* Recomputed from the event's own fields on arrival, never taken from the
|
||||
* proposal. It pins this item to one message -- which is what makes reusing
|
||||
* [nonceRandom] safe, exactly as it was when a session signed one event.
|
||||
*/
|
||||
val eventId: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness, the seed for this item's nonce. */
|
||||
val nonceRandom: HexKey,
|
||||
|
||||
/**
|
||||
* The coordinator's `AggregatedNonce` for this item once it arrives, hex.
|
||||
*
|
||||
* Written once, and written for every item of the batch in one transaction
|
||||
* alongside [FrostSigningSession.signerIds] -- so "some items aggregated" is
|
||||
* a state that cannot be reached, and `signerIds != null` is the one gate
|
||||
* the rest of the session reads.
|
||||
*/
|
||||
val aggregatedNonce: HexKey? = null,
|
||||
|
||||
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
|
||||
val signature: HexKey? = null,
|
||||
) {
|
||||
/** Whether the group has produced this item's signature. */
|
||||
fun isSigned(): Boolean = signature != null
|
||||
}
|
||||
@@ -21,22 +21,40 @@ import press.mantra.compose.managers.SharedKeyDerivation
|
||||
* convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce`
|
||||
* cannot be serialised and refuses to be used twice, by design.
|
||||
*
|
||||
* ### The nonce, and why one session means one message
|
||||
* ### One session, several events
|
||||
*
|
||||
* [nonceRandom] is secret, and regenerating this device's nonce from it is safe
|
||||
* for exactly one reason: a session signs one message and can never be made to
|
||||
* sign another. `SecretNonce.generate` mixes the message in, so the same
|
||||
* randomness under a different message would be a different nonce -- but the
|
||||
* same randomness under the same message with two *different* aggregated nonces
|
||||
* would produce two partial signatures over one secret nonce, which is how a
|
||||
* secret share is extracted.
|
||||
* A session signs a batch of one or more events, held as [FrostSigningItem]
|
||||
* rows. What lives here is what the whole batch shares, and the split is not a
|
||||
* matter of taste: an item's nonce, message and signature are the terms of
|
||||
* `s = k + e·x` with `e = H(R‖P‖m)`, so sharing any of them across two messages
|
||||
* is how a secret share is extracted. Everything on this row is outside that
|
||||
* equation.
|
||||
*
|
||||
* | shared, and so here | per item, and so on [FrostSigningItem] |
|
||||
* |---|---|
|
||||
* | ceremony, threshold, participant count | the unsigned event and its id |
|
||||
* | [signerIds] and the signer set | nonce seed, and the nonce from it |
|
||||
* | [derivationPath] and its tweak cache | aggregated nonce |
|
||||
* | [signApprovedAt] -- one decision | the signature |
|
||||
*
|
||||
* ### The nonce, and why one item means one message
|
||||
*
|
||||
* [FrostSigningItem.nonceRandom] is secret, and regenerating this device's nonce
|
||||
* from it is safe for exactly one reason: an item signs one message and can
|
||||
* never be made to sign another. `SecretNonce.generate` mixes the message in, so
|
||||
* the same randomness under a different message would be a different nonce --
|
||||
* but the same randomness under the same message with two *different* aggregated
|
||||
* nonces would produce two partial signatures over one secret nonce, which is
|
||||
* how a secret share is extracted.
|
||||
*
|
||||
* Two rules keep that impossible, and both are load-bearing:
|
||||
*
|
||||
* - [eventId] is written when the session is created and a proposal that
|
||||
* disagrees with it is rejected rather than applied.
|
||||
* - [aggregatedNonce] and [signerIds] are written once. A second, different
|
||||
* signer set for the same session is ignored, not honoured.
|
||||
* - [FrostSigningItem.eventId] is written when the session is created, and a
|
||||
* proposal that disagrees with the batch it already holds is rejected rather
|
||||
* than applied.
|
||||
* - [FrostSigningItem.aggregatedNonce] and [signerIds] are written once, and
|
||||
* written together. A second, different signer set for the same session is
|
||||
* ignored, not honoured.
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
@@ -92,38 +110,16 @@ data class FrostSigningSession(
|
||||
* it is running in. Null is the honest answer for a room that is not derived
|
||||
* from the key at all, and is what sessions predating this column read back
|
||||
* as -- both of which signed as the threshold key itself.
|
||||
*
|
||||
* Shared by the whole batch: every item of a session signs as the same room.
|
||||
*/
|
||||
val derivationPath: String? = null,
|
||||
|
||||
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
|
||||
/**
|
||||
* The unsigned event, as JSON. Kept whole so a member can be shown what
|
||||
* they are being asked to sign rather than a hash of it.
|
||||
*/
|
||||
val unsignedEventJson: String,
|
||||
|
||||
/**
|
||||
* The event id, which is the 32 bytes actually signed.
|
||||
*
|
||||
* Recomputed from the event's own fields on arrival, never taken from the
|
||||
* proposal. It pins the session to one message -- see the class note on why
|
||||
* that is what makes reusing [nonceRandom] safe.
|
||||
*/
|
||||
val eventId: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */
|
||||
val nonceRandom: HexKey,
|
||||
|
||||
/** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */
|
||||
val aggregatedNonce: HexKey? = null,
|
||||
|
||||
/** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */
|
||||
val signerIds: String? = null,
|
||||
|
||||
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
|
||||
val signature: HexKey? = null,
|
||||
|
||||
val failureReason: String? = null,
|
||||
|
||||
/**
|
||||
@@ -131,10 +127,12 @@ data class FrostSigningSession(
|
||||
* session does on their behalf. Null until they do, and nothing of theirs
|
||||
* goes out before it is set.
|
||||
*
|
||||
* One gate rather than the DKG's three. What a signer is consenting to is
|
||||
* the event, and the event is fixed before they are asked: the second round
|
||||
* puts no new question to them, so asking again would be asking the same
|
||||
* question twice about a decision already made.
|
||||
* One gate rather than the DKG's three, and one gate for the whole batch
|
||||
* rather than one per item. What a signer is consenting to is the events,
|
||||
* and the events are fixed before they are asked: the second round puts no
|
||||
* new question to them, so asking again would be asking the same question
|
||||
* twice about a decision already made. That holds for a batch only as long
|
||||
* as the member can actually see every event in it before answering.
|
||||
*/
|
||||
val signApprovedAt: Instant? = null,
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
@@ -29,6 +30,12 @@ class DatabaseFrostSigningRepository(
|
||||
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
|
||||
database.frostSigningSessionDao().observeMessages(sessionId)
|
||||
|
||||
override fun observeItems(sessionId: String): Flow<List<FrostSigningItem>> =
|
||||
database.frostSigningSessionDao().observeItems(sessionId)
|
||||
|
||||
override suspend fun getItems(sessionId: String): List<FrostSigningItem> =
|
||||
database.frostSigningSessionDao().getItems(sessionId)
|
||||
|
||||
override suspend fun getSessionById(sessionId: String): FrostSigningSession? =
|
||||
database.frostSigningSessionDao().getSessionById(sessionId)
|
||||
|
||||
@@ -92,8 +99,8 @@ class DatabaseFrostSigningRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override fun signedEvent(session: FrostSigningSession): Event? =
|
||||
FrostSigningManager.signedEvent(session)
|
||||
override fun signedEvents(items: List<FrostSigningItem>): List<Event> =
|
||||
FrostSigningManager.signedEvents(items)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DatabaseFrostSigningRepository"
|
||||
|
||||
@@ -23,6 +23,7 @@ import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
@@ -145,15 +146,11 @@ object FrostSigningManager {
|
||||
participantCount = ceremony.participantCount,
|
||||
signerId = signerId,
|
||||
derivationPath = path?.let(SharedKeyDerivation::formatPath),
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
// Fresh per session, and never reused: this is the seed the device's
|
||||
// secret nonce is regenerated from for the life of the session.
|
||||
nonceRandom = RandomInstance.bytes(32).toHex(),
|
||||
// Proposing a signature is already the act of agreeing to it.
|
||||
signApprovedAt = Clock.System.now()
|
||||
)
|
||||
database.frostSigningSessionDao().upsert(session)
|
||||
database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent)))
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}")
|
||||
@@ -163,7 +160,7 @@ object FrostSigningManager {
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = FrostSigningEvents.PROPOSAL,
|
||||
content = session.unsignedEventJson,
|
||||
content = unsignedEvent.toJson(),
|
||||
includeKey = true
|
||||
)
|
||||
|
||||
@@ -236,15 +233,16 @@ object FrostSigningManager {
|
||||
userPublicKey: HexKey
|
||||
): FrostSigningSession? {
|
||||
database.frostSigningSessionDao().getSessionById(sessionId)?.let { existing ->
|
||||
// The message a session signs is fixed at creation. A second proposal
|
||||
// under the same id carrying a different event is either a mistake or
|
||||
// an attempt to get two signatures out of one secret nonce, which is
|
||||
// how a share is extracted -- so it is refused, not applied.
|
||||
// The events a session signs are fixed at creation. A second proposal
|
||||
// under the same id carrying different ones is either a mistake or an
|
||||
// attempt to get two signatures out of one secret nonce, which is how
|
||||
// a share is extracted -- so it is refused, not applied.
|
||||
val proposed = Event.fromJsonOrNull(innerEvent.content)
|
||||
if (proposed != null && proposed.id != existing.eventId) {
|
||||
val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId }
|
||||
if (proposed != null && signing != listOf(proposed.id)) {
|
||||
logger.w(
|
||||
"Session $sessionId re-proposed with event ${proposed.id}, " +
|
||||
"but it is already signing ${existing.eventId}; ignoring"
|
||||
"but it is already signing ${signing.joinToString()}; ignoring"
|
||||
)
|
||||
}
|
||||
return existing
|
||||
@@ -312,12 +310,10 @@ object FrostSigningManager {
|
||||
threshold = key.threshold,
|
||||
participantCount = key.participantCount,
|
||||
signerId = signerId,
|
||||
derivationPath = path?.let(SharedKeyDerivation::formatPath),
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = RandomInstance.bytes(32).toHex()
|
||||
derivationPath = path?.let(SharedKeyDerivation::formatPath)
|
||||
)
|
||||
database.frostSigningSessionDao().upsert(session)
|
||||
database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent)))
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval")
|
||||
@@ -406,26 +402,19 @@ object FrostSigningManager {
|
||||
return true
|
||||
}
|
||||
|
||||
val known = current(database, session).aggregatedNonce != null
|
||||
|
||||
// Write-once, and this is the load-bearing one. Signing the same
|
||||
// message twice under one secret nonce against two different
|
||||
// aggregated nonces is exactly how a secret share is extracted, so
|
||||
// a coordinator that sends a second, different signer set is
|
||||
// ignored rather than obeyed. The session stalls; the share does
|
||||
// not leak.
|
||||
update(database, session) { current ->
|
||||
if (current.aggregatedNonce == null) {
|
||||
current.copy(
|
||||
aggregatedNonce = innerEvent.content,
|
||||
signerIds = signerIds.joinToString(",")
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
//
|
||||
// `signerIds` is the gate for the whole batch: it and every item's
|
||||
// aggregated nonce are written together, so a session holding one
|
||||
// holds all of them.
|
||||
if (current(database, session).signerIds != null) return true
|
||||
|
||||
if (!known) {
|
||||
if (applyAggregate(database, session, signerIds, listOf(innerEvent.content))) {
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
@@ -433,17 +422,12 @@ object FrostSigningManager {
|
||||
FrostSigningEvents.SIGNATURE -> {
|
||||
if (!isFromCoordinator(session, innerEvent)) return true
|
||||
|
||||
val known = current(database, session).signature != null
|
||||
// Write-once as a unit, for the same reason the aggregate is: a
|
||||
// batch that had some of its signatures would be one the group
|
||||
// could neither finish nor safely retry.
|
||||
if (isSigned(database, session)) return true
|
||||
|
||||
update(database, session) { current ->
|
||||
if (current.signature == null) {
|
||||
current.copy(signature = innerEvent.content)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
if (!known) {
|
||||
if (applySignatures(database, session, listOf(innerEvent.content))) {
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
@@ -504,7 +488,7 @@ object FrostSigningManager {
|
||||
// there -- while going on to ask would be asking for a decision that
|
||||
// can no longer change anything, and would let a late "don't sign"
|
||||
// abandon a signature that exists.
|
||||
if (session.signature != null) {
|
||||
if (isSigned(database, session)) {
|
||||
complete(database, session)
|
||||
return
|
||||
}
|
||||
@@ -526,14 +510,19 @@ object FrostSigningManager {
|
||||
val tweakCache = SharedKeyDerivation
|
||||
.derive(thresholdPublicKey, session.pathIndices())
|
||||
.cache
|
||||
val message = ByteVector(session.eventId.hexToByteArray())
|
||||
val publicShares = key.publicShareList()
|
||||
|
||||
// The events this session signs, in the order the proposal fixed. Every
|
||||
// join below is positional against it.
|
||||
val items = database.frostSigningSessionDao().getItems(sessionId)
|
||||
val item = items.firstOrNull() ?: return
|
||||
val message = ByteVector(item.eventId.hexToByteArray())
|
||||
|
||||
// Regenerated rather than stored -- SecretNonce refuses both. Safe
|
||||
// because the session's message can never change; see the notes on
|
||||
// because an item's message can never change; see the notes on
|
||||
// FrostSigningSession.
|
||||
val (secretNonce, publicNonce) = SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(session.nonceRandom),
|
||||
sessionRandom = ByteVector32(item.nonceRandom),
|
||||
secretShare = secretShare,
|
||||
publicShare = publicShares?.getOrNull(session.signerId),
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
@@ -551,26 +540,26 @@ object FrostSigningManager {
|
||||
)
|
||||
}
|
||||
|
||||
if (session.isCoordinator() && session.aggregatedNonce == null) {
|
||||
if (session.isCoordinator() && session.signerIds == null) {
|
||||
val offered = orderedNonces(database, session) ?: return
|
||||
val chosen = offered.take(session.threshold)
|
||||
|
||||
val aggregated = IndividualNonce.aggregate(chosen.map { it.second })
|
||||
.orThrow("aggregating nonces")
|
||||
|
||||
session = update(database, session) {
|
||||
it.copy(
|
||||
aggregatedNonce = aggregated.toByteArray().toHex(),
|
||||
signerIds = chosen.joinToString(",") { (id, _) -> id.toString() }
|
||||
)
|
||||
val chosenIds = chosen.map { (id, _) -> id }
|
||||
if (!applyAggregate(database, session, chosenIds, listOf(aggregated.toByteArray().toHex()))) {
|
||||
return
|
||||
}
|
||||
session = current(database, session)
|
||||
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = FrostSigningEvents.SIGNER_SET,
|
||||
content = aggregated.toByteArray().toHex(),
|
||||
signerIds = chosen.map { (id, _) -> id }
|
||||
signerIds = chosenIds
|
||||
)
|
||||
announceStep(
|
||||
database,
|
||||
@@ -580,8 +569,9 @@ object FrostSigningManager {
|
||||
)
|
||||
}
|
||||
|
||||
val aggregatedNonce = session.aggregatedNonce ?: return
|
||||
val signerIds = session.signerIdList() ?: return
|
||||
val aggregatedNonce = database.frostSigningSessionDao()
|
||||
.getItem(sessionId, item.itemIndex)?.aggregatedNonce ?: return
|
||||
session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES)
|
||||
|
||||
val signingSession = Session.create(
|
||||
@@ -613,7 +603,7 @@ object FrostSigningManager {
|
||||
)
|
||||
}
|
||||
|
||||
if (session.isCoordinator() && session.signature == null) {
|
||||
if (session.isCoordinator() && !isSigned(database, session)) {
|
||||
val partials = orderedPartialSignatures(database, session, signerIds) ?: return
|
||||
|
||||
val signature = signingSession
|
||||
@@ -621,7 +611,9 @@ object FrostSigningManager {
|
||||
.orThrow("aggregating partial signatures")
|
||||
.toHex()
|
||||
|
||||
session = update(database, session) { it.copy(signature = signature) }
|
||||
if (!applySignatures(database, session, listOf(signature))) return
|
||||
session = current(database, session)
|
||||
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
@@ -662,16 +654,25 @@ object FrostSigningManager {
|
||||
* like every other line in the transcript.
|
||||
*/
|
||||
private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) {
|
||||
val signature = session.signature ?: return
|
||||
val items = database.frostSigningSessionDao().getItems(session.id)
|
||||
if (items.isEmpty() || items.any { !it.isSigned() }) return
|
||||
|
||||
val signedEvent = signedEvent(session, signature)
|
||||
val verified = Nip01Crypto.verify(
|
||||
signature = signature.hexToByteArray(),
|
||||
hash = session.eventId.hexToByteArray(),
|
||||
pubKey = signedEvent.pubKey.hexToByteArray()
|
||||
)
|
||||
if (!verified) {
|
||||
throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}")
|
||||
// Every signature is checked before any event is applied. A batch is
|
||||
// all-or-nothing, so a bad one anywhere has to fail the session rather
|
||||
// than leave some of its events already filed.
|
||||
val signedEvents = items.map { item ->
|
||||
val signedEvent = signedEvent(item, item.signature!!)
|
||||
val verified = Nip01Crypto.verify(
|
||||
signature = item.signature.hexToByteArray(),
|
||||
hash = item.eventId.hexToByteArray(),
|
||||
pubKey = signedEvent.pubKey.hexToByteArray()
|
||||
)
|
||||
if (!verified) {
|
||||
throw IllegalStateException(
|
||||
"The aggregated signature does not verify against ${item.eventId}"
|
||||
)
|
||||
}
|
||||
signedEvent
|
||||
}
|
||||
|
||||
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
|
||||
@@ -683,14 +684,14 @@ object FrostSigningManager {
|
||||
// wire: a signed event authored by the threshold key cannot travel as
|
||||
// an inner event anyway, because the outbound pipeline re-authors
|
||||
// rumors as their sender and would strip the group's signature off.
|
||||
applySignedEvent(database, session, signedEvent)
|
||||
signedEvents.forEach { applySignedEvent(database, session, it) }
|
||||
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_FROST_COMPLETE,
|
||||
content = "The group signed the event. It took ${session.threshold} of " +
|
||||
"${session.participantCount} members.",
|
||||
content = "The group signed ${describe(signedEvents.size)}. It took " +
|
||||
"${session.threshold} of ${session.participantCount} members.",
|
||||
actor = session.coordinatorPublicKey
|
||||
)
|
||||
|
||||
@@ -736,8 +737,8 @@ object FrostSigningManager {
|
||||
* Public so a caller can take the finished event and do whatever it was
|
||||
* signing it for — the session's job ends at a valid signature.
|
||||
*/
|
||||
fun signedEvent(session: FrostSigningSession, signature: HexKey): Event {
|
||||
val unsigned = Event.fromJson(session.unsignedEventJson)
|
||||
fun signedEvent(item: FrostSigningItem, signature: HexKey): Event {
|
||||
val unsigned = Event.fromJson(item.unsignedEventJson)
|
||||
|
||||
return Event(
|
||||
id = unsigned.id,
|
||||
@@ -750,9 +751,115 @@ object FrostSigningManager {
|
||||
)
|
||||
}
|
||||
|
||||
/** The finished event, or null while the session is still running. */
|
||||
fun signedEvent(session: FrostSigningSession): Event? =
|
||||
session.signature?.let { signedEvent(session, it) }
|
||||
/** The finished event, or null while this item is still running. */
|
||||
fun signedEvent(item: FrostSigningItem): Event? =
|
||||
item.signature?.let { signedEvent(item, it) }
|
||||
|
||||
/** Every finished event of a batch, empty while any of them is still running. */
|
||||
fun signedEvents(items: List<FrostSigningItem>): List<Event> =
|
||||
items.map { it.signature ?: return emptyList() }
|
||||
.mapIndexed { index, signature -> signedEvent(items[index], signature) }
|
||||
|
||||
/**
|
||||
* The items a session signs: one per event, each with its own nonce seed.
|
||||
*
|
||||
* Independent seeds rather than one derived per index, which would work and
|
||||
* save nothing worth having. Independence means an off-by-one anywhere in
|
||||
* the index handling produces a session that fails to aggregate, instead of
|
||||
* one that signs two messages under a single nonce.
|
||||
*/
|
||||
private fun itemsOver(sessionId: String, events: List<Event>): List<FrostSigningItem> =
|
||||
events.mapIndexed { index, event ->
|
||||
FrostSigningItem(
|
||||
sessionId = sessionId,
|
||||
itemIndex = index,
|
||||
unsignedEventJson = event.toJson(),
|
||||
eventId = event.id,
|
||||
nonceRandom = RandomInstance.bytes(32).toHex()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the coordinator's signer set and the aggregated nonce each item is
|
||||
* signed against, as one write. Returns false when the message does not fit
|
||||
* the batch, so the caller neither announces nor acts on it.
|
||||
*
|
||||
* The two belong together. [FrostSigningSession.signerIds] is what the rest
|
||||
* of the session gates on, so a session holding it while an item still had no
|
||||
* aggregate would stall on a message that has already been delivered — and
|
||||
* the write that cleared the stall would be a second aggregate for an item
|
||||
* that had one, which is the case the write-once rule exists to prevent.
|
||||
* Items first, in one transaction, then the session: the gate is only ever
|
||||
* set on a batch that is entirely ready.
|
||||
*/
|
||||
private suspend fun applyAggregate(
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession,
|
||||
signerIds: List<Int>,
|
||||
aggregatedNonces: List<HexKey>
|
||||
): Boolean {
|
||||
val items = database.frostSigningSessionDao().getItems(session.id)
|
||||
if (items.isEmpty() || items.size != aggregatedNonces.size) {
|
||||
logger.w(
|
||||
"Session ${session.id}: signer set carries ${aggregatedNonces.size} " +
|
||||
"aggregated nonce(s) for ${items.size} item(s); ignoring"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
database.frostSigningSessionDao().upsertItems(
|
||||
items.mapIndexed { index, item -> item.copy(aggregatedNonce = aggregatedNonces[index]) }
|
||||
)
|
||||
update(database, session) { it.copy(signerIds = signerIds.joinToString(",")) }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the group's finished signatures, all of them or none. Returns false
|
||||
* when the message does not fit the batch.
|
||||
*
|
||||
* Settled as a unit for the same reason the aggregate is: a batch holding
|
||||
* some of its signatures is one the group can neither finish nor safely
|
||||
* retry, since a retry means fresh nonces for events that already have a
|
||||
* signature against the old ones.
|
||||
*/
|
||||
private suspend fun applySignatures(
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession,
|
||||
signatures: List<HexKey>
|
||||
): Boolean {
|
||||
val items = database.frostSigningSessionDao().getItems(session.id)
|
||||
if (items.isEmpty() || items.size != signatures.size) {
|
||||
logger.w(
|
||||
"Session ${session.id}: ${signatures.size} signature(s) for " +
|
||||
"${items.size} item(s); ignoring"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
database.frostSigningSessionDao().upsertItems(
|
||||
items.mapIndexed { index, item -> item.copy(signature = signatures[index]) }
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the group has produced every signature this session was opened for.
|
||||
*
|
||||
* Counted rather than flagged, so it cannot disagree with the rows it
|
||||
* describes. A session with no items is not signed — it is one whose proposal
|
||||
* has not landed yet.
|
||||
*/
|
||||
private suspend fun isSigned(database: MantraDatabase, session: FrostSigningSession): Boolean {
|
||||
val total = database.frostSigningSessionDao().countItems(session.id)
|
||||
|
||||
return total > 0 && database.frostSigningSessionDao().countSignedItems(session.id) == total
|
||||
}
|
||||
|
||||
/** "the event" or "3 events", for a transcript line that reads the same at either size. */
|
||||
private fun describe(count: Int): String = if (count == 1) "the event" else "$count events"
|
||||
|
||||
/**
|
||||
* The nonces on offer, as (signer id, nonce), ordered by signer id — or null
|
||||
@@ -1091,7 +1198,10 @@ object FrostSigningManager {
|
||||
* it. The two must agree, or the screen offers an approval that does nothing,
|
||||
* or none while the session sits still.
|
||||
*/
|
||||
fun isAwaitingApproval(session: FrostSigningSession): Boolean {
|
||||
fun isAwaitingApproval(
|
||||
session: FrostSigningSession,
|
||||
items: List<FrostSigningItem>
|
||||
): Boolean {
|
||||
if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) {
|
||||
return false
|
||||
}
|
||||
@@ -1100,7 +1210,7 @@ object FrostSigningManager {
|
||||
// the session on its next pass without asking them anything. Offering the
|
||||
// decision anyway would be offering two bad answers: a nonce nobody is
|
||||
// waiting for, or a refusal that abandons a signature already made.
|
||||
if (session.signature != null) return false
|
||||
if (items.isNotEmpty() && items.all { it.isSigned() }) return false
|
||||
|
||||
return session.signApprovedAt == null
|
||||
}
|
||||
@@ -1118,8 +1228,9 @@ object FrostSigningManager {
|
||||
sessionId: String
|
||||
) {
|
||||
val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return
|
||||
val items = database.frostSigningSessionDao().getItems(sessionId)
|
||||
|
||||
if (!isAwaitingApproval(session)) {
|
||||
if (!isAwaitingApproval(session, items)) {
|
||||
logger.i("Session $sessionId is not waiting on an approval; ignoring it")
|
||||
return
|
||||
}
|
||||
@@ -1145,8 +1256,9 @@ object FrostSigningManager {
|
||||
sessionId: String
|
||||
) {
|
||||
val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return
|
||||
val items = database.frostSigningSessionDao().getItems(sessionId)
|
||||
|
||||
if (!isAwaitingApproval(session)) return
|
||||
if (!isAwaitingApproval(session, items)) return
|
||||
|
||||
fail(
|
||||
database = database,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
@@ -24,6 +25,17 @@ interface FrostSigningRepository {
|
||||
|
||||
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
|
||||
|
||||
/**
|
||||
* The events a session signs, in the order its proposal fixed.
|
||||
*
|
||||
* Separate from the session because a session signs a batch, and what
|
||||
* separates one event of it from another is forced by FROST rather than
|
||||
* chosen — see [FrostSigningItem].
|
||||
*/
|
||||
fun observeItems(sessionId: String): Flow<List<FrostSigningItem>>
|
||||
|
||||
suspend fun getItems(sessionId: String): List<FrostSigningItem>
|
||||
|
||||
suspend fun getSessionById(sessionId: String): FrostSigningSession?
|
||||
|
||||
/**
|
||||
@@ -58,8 +70,8 @@ interface FrostSigningRepository {
|
||||
/** Refuses, and says so, since a t-of-n group can proceed without this member. */
|
||||
suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String)
|
||||
|
||||
/** The finished event, or null while the session is still running. */
|
||||
fun signedEvent(session: FrostSigningSession): Event?
|
||||
/** The finished events, or empty while any of them is still running. */
|
||||
fun signedEvents(items: List<FrostSigningItem>): List<Event>
|
||||
|
||||
companion object {
|
||||
val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository {
|
||||
@@ -71,6 +83,11 @@ interface FrostSigningRepository {
|
||||
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
|
||||
flowOf(emptyList())
|
||||
|
||||
override fun observeItems(sessionId: String): Flow<List<FrostSigningItem>> =
|
||||
flowOf(emptyList())
|
||||
|
||||
override suspend fun getItems(sessionId: String): List<FrostSigningItem> = emptyList()
|
||||
|
||||
override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null
|
||||
|
||||
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null
|
||||
@@ -89,7 +106,7 @@ interface FrostSigningRepository {
|
||||
|
||||
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
|
||||
|
||||
override fun signedEvent(session: FrostSigningSession): Event? = null
|
||||
override fun signedEvents(items: List<FrostSigningItem>): List<Event> = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ fun FrostSigningScreen(
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp)
|
||||
) {
|
||||
WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session))
|
||||
WhatIsBeingSigned(frostSigningViewModel.proposedEvents(state.items).firstOrNull())
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
@@ -229,7 +229,7 @@ fun FrostSigningScreen(
|
||||
// Asked rather than re-derived: the manager owns when a session
|
||||
// is still waiting on its owner, and a second copy of that rule
|
||||
// here is a second copy to keep in step.
|
||||
if (FrostSigningManager.isAwaitingApproval(session)) {
|
||||
if (FrostSigningManager.isAwaitingApproval(session, state.items)) {
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
@@ -66,12 +67,14 @@ class FrostSigningViewModel(
|
||||
|
||||
combine(
|
||||
frostSigningRepository.observeSessionById(id),
|
||||
frostSigningRepository.observeMessages(id)
|
||||
) { session, messages -> session to messages }
|
||||
.collect { (session, messages) ->
|
||||
frostSigningRepository.observeMessages(id),
|
||||
frostSigningRepository.observeItems(id)
|
||||
) { session, messages, items -> Triple(session, messages, items) }
|
||||
.collect { (session, messages, items) ->
|
||||
frostSigningUIState = FrostSigningUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
items = items,
|
||||
offeredNonce = messages
|
||||
.filter { it.kind == FrostSigningEvents.NONCE }
|
||||
.map { it.signerPublicKey }
|
||||
@@ -85,12 +88,12 @@ class FrostSigningViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** The event the group is being asked to sign, for showing it before they agree. */
|
||||
fun proposedEvent(session: FrostSigningSession): Event? =
|
||||
Event.fromJsonOrNull(session.unsignedEventJson)
|
||||
/** The events the group is being asked to sign, for showing them before they agree. */
|
||||
fun proposedEvents(items: List<FrostSigningItem>): List<Event> =
|
||||
items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) }
|
||||
|
||||
fun signedEvent(session: FrostSigningSession): Event? =
|
||||
frostSigningRepository.signedEvent(session)
|
||||
fun signedEvents(items: List<FrostSigningItem>): List<Event> =
|
||||
frostSigningRepository.signedEvents(items)
|
||||
|
||||
fun approve(onDone: () -> Unit) {
|
||||
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
@@ -11,6 +12,12 @@ sealed interface FrostSigningUIState {
|
||||
/** Null on the first emission, before the session has been collected. */
|
||||
val session: FrostSigningSession? = null,
|
||||
|
||||
/**
|
||||
* The events the session signs, in the order its proposal fixed. Empty
|
||||
* on the first emission, and for a session whose proposal has not landed.
|
||||
*/
|
||||
val items: List<FrostSigningItem> = emptyList(),
|
||||
|
||||
/** Who has offered a nonce, so the screen can name who it is waiting on. */
|
||||
val offeredNonce: Set<HexKey> = emptySet(),
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
@@ -227,20 +228,30 @@ class FrostSigningSessionTest {
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = signerId,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
/** The one event such a session signs, signed or not. */
|
||||
private fun items(signature: String? = null) = listOf(
|
||||
FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signature = signature
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a session waits on its owner until they answer`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(open))
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(open, items()))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
|
||||
open.copy(signApprovedAt = Instant.fromEpochSeconds(1)),
|
||||
items()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -249,8 +260,12 @@ class FrostSigningSessionTest {
|
||||
fun `a session that has settled asks its owner nothing`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items())
|
||||
)
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items())
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,9 +276,10 @@ class FrostSigningSessionTest {
|
||||
// decision in that window offers two bad answers: a nonce nobody is
|
||||
// waiting for, or a refusal that abandons a signature that exists.
|
||||
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
|
||||
.copy(signature = "a".repeat(128))
|
||||
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -415,7 +431,7 @@ class FrostSigningCompletionTest {
|
||||
* never named them. Every column the completion path reads is here; the ones
|
||||
* it must not need are deliberately left null.
|
||||
*/
|
||||
private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession(
|
||||
private fun leftOutMemberSession() = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
@@ -425,24 +441,30 @@ class FrostSigningCompletionTest {
|
||||
participantCount = 3,
|
||||
signerId = 2,
|
||||
derivationPath = SharedKeyDerivation.formatPath(),
|
||||
signerIds = null,
|
||||
signApprovedAt = null
|
||||
)
|
||||
|
||||
/** The event that session signs: everything completing it needs, and nothing more. */
|
||||
private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64),
|
||||
aggregatedNonce = null,
|
||||
signerIds = null,
|
||||
signature = signature,
|
||||
signApprovedAt = null
|
||||
signature = signature
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member who never took part can still check what the group signed`() {
|
||||
val session = leftOutMemberSession(signature)
|
||||
val signed = FrostSigningManager.signedEvent(session)!!
|
||||
val item = leftOutMemberItem(signature)
|
||||
val signed = FrostSigningManager.signedEvent(item)!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signed.sig.hexToByteArray(),
|
||||
hash = session.eventId.hexToByteArray(),
|
||||
hash = item.eventId.hexToByteArray(),
|
||||
pubKey = signed.pubKey.hexToByteArray()
|
||||
),
|
||||
"completing must need only the row: the event, its id and the signature"
|
||||
@@ -454,7 +476,7 @@ class FrostSigningCompletionTest {
|
||||
// Not rebuilt and not rehashed: the id a session is pinned to is the id
|
||||
// the signature is over, so anything that changed here would produce an
|
||||
// event whose signature verifies against nothing.
|
||||
val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!!
|
||||
val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!!
|
||||
|
||||
assertEquals(unsignedEvent.id, signed.id)
|
||||
assertEquals(unsignedEvent.pubKey, signed.pubKey)
|
||||
@@ -466,15 +488,22 @@ class FrostSigningCompletionTest {
|
||||
|
||||
@Test
|
||||
fun `there is no finished event until the signature arrives`() {
|
||||
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
|
||||
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the arrived signature is what stops the session asking`() {
|
||||
// The pair that matters to the screen and the transcript: the same row,
|
||||
// before and after the group finished without this member.
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession()))
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature)))
|
||||
assertTrue(
|
||||
FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem()))
|
||||
)
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
leftOutMemberSession(),
|
||||
listOf(leftOutMemberItem(signature))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -482,7 +511,7 @@ class FrostSigningCompletionTest {
|
||||
// What the check is for. A coordinator passing off something else must not
|
||||
// get it applied and announced as the group's, and the row is all there is
|
||||
// to catch it with.
|
||||
val other = leftOutMemberSession(signature).copy(
|
||||
val other = leftOutMemberItem(signature).copy(
|
||||
eventId = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
|
||||
@@ -19,7 +19,7 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
@@ -281,21 +281,14 @@ class GroupKeyStateTest {
|
||||
val unsigned = unsignedKeyState(content, tags, createdAt, author)
|
||||
|
||||
return FrostSigningManager.signedEvent(
|
||||
session = sessionOver(unsigned),
|
||||
item = itemOver(unsigned),
|
||||
signature = groupSignature(signer, unsigned.id)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = "c00rd1na70r",
|
||||
userPublicKey = "c00rd1na70r",
|
||||
dkgSessionId = "ceremony-1",
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
signerId = 0,
|
||||
derivationPath = SharedKeyDerivation.formatPath(path),
|
||||
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64)
|
||||
|
||||
@@ -170,9 +170,6 @@ class SharedKeyDerivationTest {
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = 0,
|
||||
derivationPath = derivationPath,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64)
|
||||
derivationPath = derivationPath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.extensions.toHex
|
||||
@@ -100,24 +100,17 @@ class SignedArtifactTest {
|
||||
sig = ""
|
||||
)
|
||||
|
||||
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = proposer,
|
||||
userPublicKey = proposer,
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
signerId = 0,
|
||||
derivationPath = SharedKeyDerivation.formatPath(),
|
||||
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
|
||||
sessionId = "s".repeat(64),
|
||||
itemIndex = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64)
|
||||
)
|
||||
|
||||
/** A quorum signing the session's event, in the manager's order. */
|
||||
private fun groupSignature(session: FrostSigningSession): String {
|
||||
val message = ByteVector(session.eventId.hexToByteArray())
|
||||
/** A quorum signing the item's event, in the manager's order. */
|
||||
private fun groupSignature(item: FrostSigningItem): String {
|
||||
val message = ByteVector(item.eventId.hexToByteArray())
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
@@ -154,8 +147,8 @@ class SignedArtifactTest {
|
||||
|
||||
/** Everything from the form to the row a device holds afterwards. */
|
||||
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
|
||||
val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel)))
|
||||
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
|
||||
|
||||
return ArtifactEvent(
|
||||
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
|
||||
@@ -193,15 +186,15 @@ class SignedArtifactTest {
|
||||
// to be the one that was signed rather than anything recomputed from the
|
||||
// proposer. Otherwise members converge on nothing and each holds its own
|
||||
// copy of what is meant to be one artifact.
|
||||
val session = sessionOver(unsignedEventOf(proposalTemplate()))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
val item = itemOver(unsignedEventOf(proposalTemplate()))
|
||||
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
|
||||
|
||||
val artifact = MantraArtifact.fromArtifactEvent(
|
||||
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
|
||||
chatRoomId
|
||||
)
|
||||
|
||||
assertEquals(session.eventId, artifact?.id)
|
||||
assertEquals(item.eventId, artifact?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -8,6 +8,7 @@ import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
@@ -81,6 +82,7 @@ class FrostSigningSessionDaoJvmTest {
|
||||
createdAt: Instant = Instant.fromEpochSeconds(1_000),
|
||||
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
signature: String? = null,
|
||||
events: Int = 1,
|
||||
): FrostSigningSession = FrostSigningSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
@@ -91,12 +93,33 @@ class FrostSigningSessionDaoJvmTest {
|
||||
participantCount = 3,
|
||||
signerId = 1,
|
||||
stage = stage,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "ff".repeat(32),
|
||||
nonceRandom = "aa".repeat(32),
|
||||
signature = signature,
|
||||
createdAt = createdAt,
|
||||
).also { db.frostSigningSessionDao().upsert(it) }
|
||||
).also {
|
||||
db.frostSigningSessionDao().upsert(it)
|
||||
db.frostSigningSessionDao().upsertItems(
|
||||
(0 until events).map { index -> item(id, index, signature) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One event of a session's batch. Distinct ids and seeds per index, because
|
||||
* that is what the manager writes and what every ordering assertion here
|
||||
* would otherwise be blind to.
|
||||
*/
|
||||
private fun item(
|
||||
sessionId: String,
|
||||
itemIndex: Int = 0,
|
||||
signature: String? = null,
|
||||
aggregatedNonce: String? = null,
|
||||
) = FrostSigningItem(
|
||||
sessionId = sessionId,
|
||||
itemIndex = itemIndex,
|
||||
unsignedEventJson = """{"index":$itemIndex}""",
|
||||
eventId = "f$itemIndex".repeat(32),
|
||||
nonceRandom = "a$itemIndex".repeat(32),
|
||||
aggregatedNonce = aggregatedNonce,
|
||||
signature = signature,
|
||||
)
|
||||
|
||||
private suspend fun message(
|
||||
sessionId: String,
|
||||
@@ -242,6 +265,88 @@ class FrostSigningSessionDaoJvmTest {
|
||||
val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1"))
|
||||
|
||||
assertEquals(FrostSigningStage.COMPLETE, found.stage)
|
||||
assertEquals("ab".repeat(32), found.signature)
|
||||
assertEquals("ab".repeat(32), db.frostSigningSessionDao().getItems("s1").single().signature)
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch's items come back in the order the proposal fixed, whatever order
|
||||
* they went in.
|
||||
*
|
||||
* Not a presentation detail. Nonces and partial signatures are joined
|
||||
* positionally against this list, so a device reading a batch in a different
|
||||
* order aggregates item 2's partial signature against item 0's message and
|
||||
* produces a signature nobody can verify.
|
||||
*/
|
||||
@Test
|
||||
fun `a batch's items read back in index order`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
|
||||
db.frostSigningSessionDao().upsertItems(
|
||||
listOf(item("s1", itemIndex = 3), item("s1", itemIndex = 1), item("s1", itemIndex = 2))
|
||||
)
|
||||
|
||||
val items = db.frostSigningSessionDao().getItems("s1")
|
||||
|
||||
assertEquals(listOf(0, 1, 2, 3), items.map { it.itemIndex })
|
||||
assertEquals(4, db.frostSigningSessionDao().countItems("s1"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A redelivered item overwrites rather than accumulates, which is what the
|
||||
* composite key buys — the same property the signer messages rely on, and for
|
||||
* the same reason: a second row would make the batch the wrong length, and
|
||||
* the length is what every payload is checked against.
|
||||
*/
|
||||
@Test
|
||||
fun `a second write of one item replaces it`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1")
|
||||
|
||||
db.frostSigningSessionDao().upsert(item("s1", signature = "cd".repeat(32)))
|
||||
|
||||
val items = db.frostSigningSessionDao().getItems("s1")
|
||||
|
||||
assertEquals(1, items.size)
|
||||
assertEquals("cd".repeat(32), items.single().signature)
|
||||
}
|
||||
|
||||
/** Counted rather than flagged, so "the group has finished" cannot disagree with the rows. */
|
||||
@Test
|
||||
fun `signed items are counted apart from the rest`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1", events = 3)
|
||||
|
||||
assertEquals(3, db.frostSigningSessionDao().countItems("s1"))
|
||||
assertEquals(0, db.frostSigningSessionDao().countSignedItems("s1"))
|
||||
|
||||
db.frostSigningSessionDao().upsertItems(
|
||||
listOf(item("s1", 0, signature = "ab".repeat(32)), item("s1", 1, signature = "cd".repeat(32)))
|
||||
)
|
||||
|
||||
assertEquals(2, db.frostSigningSessionDao().countSignedItems("s1"))
|
||||
}
|
||||
|
||||
/** Items belong to their session and go with it, like the signer messages do. */
|
||||
@Test
|
||||
fun `deleting a room takes its sessions' items with it`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1", chatRoomId = roomOne, events = 2)
|
||||
session("s2", chatRoomId = roomTwo, events = 2)
|
||||
|
||||
db.chatRoomDao().delete(assertNotNull(db.chatRoomDao().findChatRoomById(roomOne)).chatRoom)
|
||||
|
||||
assertEquals(emptyList(), db.frostSigningSessionDao().getItems("s1"))
|
||||
assertEquals(2, db.frostSigningSessionDao().getItems("s2").size)
|
||||
}
|
||||
|
||||
/** The single-item read the manager uses to pick one event out of a batch. */
|
||||
@Test
|
||||
fun `an item can be read by its index`() = runBlocking {
|
||||
seedRooms()
|
||||
session("s1", events = 3)
|
||||
|
||||
assertEquals("f2".repeat(32), db.frostSigningSessionDao().getItem("s1", 2)?.eventId)
|
||||
assertNull(db.frostSigningSessionDao().getItem("s1", 3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.sqlite.execSQL
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The v9 -> v10 backfill, against a real database holding a real half-finished
|
||||
* session.
|
||||
*
|
||||
* The stake is higher than a migration test usually carries. A session in flight
|
||||
* at upgrade time holds `nonceRandom`, the seed its secret nonce is regenerated
|
||||
* from, and `aggregatedNonce`, the aggregate its partial signature is made
|
||||
* against. If either fails to arrive at item 0, the session's next pass derives a
|
||||
* *different* nonce for the same message and publishes a second partial signature
|
||||
* over it -- two partial signatures, one message, two nonces, which is how a
|
||||
* secret share is extracted. So this asserts the values, not merely that a row
|
||||
* appeared.
|
||||
*
|
||||
* Run against the migration's own SQL on a bare connection rather than through
|
||||
* Room. That covers the copy and the shape it leaves behind, which is what this
|
||||
* migration is; it does not cover Room's version wiring, which belongs to
|
||||
* `PlatformDatabaseBuilder` and is the same for every migration in that list.
|
||||
*/
|
||||
class FrostSigningItemMigrationJvmTest {
|
||||
|
||||
private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:")
|
||||
|
||||
@AfterTest
|
||||
fun close() = connection.close()
|
||||
|
||||
/** v9's `FrostSigningSession`, verbatim from `schemas/9.json`. */
|
||||
private fun createV9() {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `FrostSigningSession` (`id` TEXT NOT NULL, " +
|
||||
"`chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, " +
|
||||
"`userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, " +
|
||||
"`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " +
|
||||
"`signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, " +
|
||||
"`unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, " +
|
||||
"`nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, " +
|
||||
"`signature` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, " +
|
||||
"`approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " +
|
||||
"`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
|
||||
"FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` " +
|
||||
"ON `FrostSigningSession` (`chatRoomId`)"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` " +
|
||||
"ON `FrostSigningSession` (`dkgSessionId`)"
|
||||
)
|
||||
}
|
||||
|
||||
/** A session mid-flight: it has its seed, and the aggregate it is signing against. */
|
||||
private fun insertV9Session(
|
||||
id: String = "s1",
|
||||
aggregatedNonce: String? = null,
|
||||
signature: String? = null,
|
||||
) = connection.execSQL(
|
||||
"INSERT INTO `FrostSigningSession` VALUES (" +
|
||||
"'$id', 'room', 'coord', 'user', 'dkg-1', 2, 3, 1, 'm/9420/0/0', 'COLLECTING_NONCES', " +
|
||||
"'$unsignedEventJson', '$eventId', '$nonceRandom', " +
|
||||
"${aggregatedNonce.orNull()}, '0,1', ${signature.orNull()}, " +
|
||||
"NULL, 1, 1, 1000, 1000, 1000)"
|
||||
)
|
||||
|
||||
private fun String?.orNull(): String = this?.let { "'$it'" } ?: "NULL"
|
||||
|
||||
/** Column names in declaration order, which is what Room checks the table against. */
|
||||
private fun columns(table: String): List<String> =
|
||||
connection.prepare("PRAGMA table_info(`$table`)").use { statement ->
|
||||
buildList { while (statement.step()) add(statement.getText(1)) }
|
||||
}
|
||||
|
||||
/** One column of the one row, as text, or null when the column is null. */
|
||||
private fun read(table: String, column: String): String? =
|
||||
connection.prepare("SELECT `$column` FROM `$table`").use { statement ->
|
||||
if (statement.step() && !statement.isNull(0)) statement.getText(0) else null
|
||||
}
|
||||
|
||||
private val unsignedEventJson = """{"kind":1}"""
|
||||
private val eventId = "ff".repeat(32)
|
||||
private val nonceRandom = "aa".repeat(32)
|
||||
private val aggregatedNonce = "bb".repeat(66)
|
||||
private val signature = "cc".repeat(32)
|
||||
|
||||
@Test
|
||||
fun `an in-flight session keeps the seed and the aggregate it is already signing against`() =
|
||||
runBlocking {
|
||||
createV9()
|
||||
insertV9Session(aggregatedNonce = aggregatedNonce)
|
||||
|
||||
MIGRATION_9_10.migrate(connection)
|
||||
|
||||
// The two that cannot be regenerated. Losing either turns this
|
||||
// session's next pass into a second partial signature over one message.
|
||||
assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom"))
|
||||
assertEquals(aggregatedNonce, read("FrostSigningItem", "aggregatedNonce"))
|
||||
|
||||
assertEquals("0", read("FrostSigningItem", "itemIndex"))
|
||||
assertEquals(eventId, read("FrostSigningItem", "eventId"))
|
||||
assertEquals(unsignedEventJson, read("FrostSigningItem", "unsignedEventJson"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished session keeps its signature`() = runBlocking {
|
||||
createV9()
|
||||
insertV9Session(aggregatedNonce = aggregatedNonce, signature = signature)
|
||||
|
||||
MIGRATION_9_10.migrate(connection)
|
||||
|
||||
assertEquals(signature, read("FrostSigningItem", "signature"))
|
||||
}
|
||||
|
||||
/** A session that had not been aggregated yet arrives with nothing invented for it. */
|
||||
@Test
|
||||
fun `a session still collecting nonces gets an item with no aggregate`() = runBlocking {
|
||||
createV9()
|
||||
insertV9Session()
|
||||
|
||||
MIGRATION_9_10.migrate(connection)
|
||||
|
||||
assertEquals(null, read("FrostSigningItem", "aggregatedNonce"))
|
||||
assertEquals(null, read("FrostSigningItem", "signature"))
|
||||
assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape Room checks on the next open. A column left behind, or one
|
||||
* missing, is refused at open time -- a crash on launch rather than a bug
|
||||
* anybody gets to debug.
|
||||
*/
|
||||
@Test
|
||||
fun `the tables are left with exactly the columns v10 declares`() = runBlocking {
|
||||
createV9()
|
||||
insertV9Session()
|
||||
|
||||
MIGRATION_9_10.migrate(connection)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"id", "chatRoomId", "coordinatorPublicKey", "userPublicKey", "dkgSessionId",
|
||||
"threshold", "participantCount", "signerId", "derivationPath", "stage",
|
||||
"signerIds", "failureReason", "signApprovedAt", "approvalRequestedAt",
|
||||
"createdAt", "updatedAt", "savedAt",
|
||||
),
|
||||
columns("FrostSigningSession")
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"sessionId", "itemIndex", "unsignedEventJson", "eventId",
|
||||
"nonceRandom", "aggregatedNonce", "signature",
|
||||
),
|
||||
columns("FrostSigningItem")
|
||||
)
|
||||
}
|
||||
|
||||
/** `signerIds` is shared by every item of a batch, so it stays where it was. */
|
||||
@Test
|
||||
fun `the signer set stays on the session`() = runBlocking {
|
||||
createV9()
|
||||
insertV9Session()
|
||||
|
||||
MIGRATION_9_10.migrate(connection)
|
||||
|
||||
assertEquals("0,1", read("FrostSigningSession", "signerIds"))
|
||||
}
|
||||
|
||||
/** An empty table migrates to an empty table rather than to one bad row. */
|
||||
@Test
|
||||
fun `a database with no sessions gets no items`() = runBlocking {
|
||||
createV9()
|
||||
|
||||
MIGRATION_9_10.migrate(connection)
|
||||
|
||||
assertEquals(null, read("FrostSigningItem", "sessionId"))
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,13 @@ class SignedGroupKeyStateTest {
|
||||
suspend fun session(sessionId: String): FrostSigningSession? =
|
||||
db.frostSigningSessionDao().getSessionById(sessionId)
|
||||
|
||||
/** The events a session signs, in the order its proposal fixed. */
|
||||
suspend fun items(sessionId: String) =
|
||||
db.frostSigningSessionDao().getItems(sessionId)
|
||||
|
||||
/** The one event a session of one signs. */
|
||||
suspend fun item(sessionId: String) = items(sessionId).single()
|
||||
|
||||
suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId)
|
||||
}
|
||||
|
||||
@@ -275,7 +282,7 @@ class SignedGroupKeyStateTest {
|
||||
val payload = Event.fromJson(proposal.content)
|
||||
assertEquals(GroupKeyStateEvent.KIND, payload.kind)
|
||||
assertEquals(thresholdPublicKey, payload.content)
|
||||
assertEquals(session.eventId, payload.id)
|
||||
assertEquals(creator.item(session.id).eventId, payload.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -289,7 +296,7 @@ class SignedGroupKeyStateTest {
|
||||
key = ceremonyOn(creator)
|
||||
)
|
||||
|
||||
val payload = Event.fromJson(session.unsignedEventJson)
|
||||
val payload = Event.fromJson(creator.item(session.id).unsignedEventJson)
|
||||
|
||||
// Signing runs at the room's derivation path, so the key the group signs
|
||||
// as is the room's own id. Not the group's root key, which is what an
|
||||
@@ -320,7 +327,7 @@ class SignedGroupKeyStateTest {
|
||||
// Rebuilt from the event's own fields under this device's own reading of
|
||||
// the room. Agreeing on the id is agreeing on every byte that is signed,
|
||||
// the author included.
|
||||
assertEquals(session.eventId, received.eventId)
|
||||
assertEquals(creator.item(session.id).eventId, other.item(session.id).eventId)
|
||||
assertEquals(session.derivationPath, received.derivationPath)
|
||||
}
|
||||
|
||||
@@ -337,7 +344,12 @@ class SignedGroupKeyStateTest {
|
||||
)
|
||||
pump(creator, other)
|
||||
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(other.session(session.id)!!))
|
||||
assertTrue(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
other.session(session.id)!!,
|
||||
other.items(session.id)
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
outbox(other).isEmpty(),
|
||||
"a device that has not been asked yet must publish nothing"
|
||||
@@ -394,7 +406,7 @@ class SignedGroupKeyStateTest {
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
val signed = FrostSigningManager.signedEvent(creator.session(session.id)!!)
|
||||
val signed = FrostSigningManager.signedEvent(creator.item(session.id))
|
||||
assertNotNull(signed, "a completed session must carry a signed event")
|
||||
|
||||
assertEquals(adminRoomId, signed.pubKey)
|
||||
@@ -467,7 +479,7 @@ class SignedGroupKeyStateTest {
|
||||
pump(creator, other)
|
||||
|
||||
listOf(creator, other).forEach { device ->
|
||||
val dialect = device.db.mantraDialectDao().getDialectById(session.eventId)
|
||||
val dialect = device.db.mantraDialectDao().getDialectById(device.item(session.id).eventId)
|
||||
assertNotNull(dialect, "a signed dialect should exist on every signer's device")
|
||||
assertEquals("Sepedi", dialect.name)
|
||||
// The ask this whole change serves: the group's work is authored by
|
||||
@@ -601,7 +613,7 @@ class SignedGroupKeyStateTest {
|
||||
)
|
||||
|
||||
assertEquals(SharedKeyDerivation.formatPath(sibling), session.derivationPath)
|
||||
assertEquals(siblingRoomId, Event.fromJson(session.unsignedEventJson).pubKey)
|
||||
assertEquals(siblingRoomId, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey)
|
||||
|
||||
pump(creator, other)
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
@@ -640,6 +652,6 @@ class SignedGroupKeyStateTest {
|
||||
|
||||
assertNull(session.derivationPath, "no path reaches a room that was not derived")
|
||||
assertEquals(emptyList(), session.pathIndices())
|
||||
assertEquals(rootPublicKey, Event.fromJson(session.unsignedEventJson).pubKey)
|
||||
assertEquals(rootPublicKey, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ silent, or a decision that looked arbitrary and was not.
|
||||
|---|---|
|
||||
| [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
|
||||
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
|
||||
| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
|
||||
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
|
||||
| [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it |
|
||||
| [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
|
||||
@@ -19,4 +20,6 @@ Start with the ceremony if you are new to this area; the Marmot notes all assume
|
||||
Read the skipped-keys note before debugging any "the other device never got it"
|
||||
report — it is silent, and it looks like every other kind of delivery failure. The
|
||||
sync note stands alone, and the dead-code inventory reads as a follow-up to it. The
|
||||
batch-signing note is a plan rather than a description of what is there: read it
|
||||
after the derivation note, whose one rule is the same one it is built around. The
|
||||
jvm-target note is unrelated to all of them: it is a build and packaging story.
|
||||
|
||||
450
docs/frost-batch-signing.md
Normal file
450
docs/frost-batch-signing.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user