Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64
This commit is contained in:
commit
60abf243ed
30 files changed
+8209
-320
No files matched your search
@@ -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)
|
||||
|
||||
+6
-3
@@ -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()
|
||||
|
||||
+42
@@ -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>
|
||||
|
||||
|
||||
+90
@@ -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`")
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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
|
||||
}
|
||||
+38
-40
@@ -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,
|
||||
|
||||
|
||||
+37
-6
@@ -4,10 +4,12 @@ import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
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 +31,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)
|
||||
|
||||
@@ -49,7 +57,7 @@ class DatabaseFrostSigningRepository(
|
||||
kind: Kind,
|
||||
tags: Array<Array<String>>,
|
||||
content: String
|
||||
): FrostSigningSession? = try {
|
||||
): FrostSigningSession? = proposing(localChatRoom) {
|
||||
FrostSigningManager.proposeSigning(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
@@ -58,10 +66,33 @@ class DatabaseFrostSigningRepository(
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun proposeSigningBatch(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
events: List<EventTemplate<*>>
|
||||
): FrostSigningSession? = proposing(localChatRoom) {
|
||||
FrostSigningManager.proposeSigningBatch(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
events = events
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposing throws when the group has no key, when this device was not in the
|
||||
* ceremony, or when a batch is empty or over the cap. All four are states the
|
||||
* UI is supposed to have checked for, so they become a null the caller
|
||||
* reports rather than a crash.
|
||||
*/
|
||||
private inline fun proposing(
|
||||
localChatRoom: LocalChatRoom,
|
||||
propose: () -> FrostSigningSession
|
||||
): FrostSigningSession? = try {
|
||||
propose()
|
||||
} catch (e: Throwable) {
|
||||
// Proposing throws when the group has no key or this device was not in the
|
||||
// ceremony. Both are states the UI is supposed to have checked for, so this
|
||||
// is a null the caller reports rather than a crash.
|
||||
logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e)
|
||||
null
|
||||
}
|
||||
@@ -92,8 +123,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"
|
||||
|
||||
+22
-1
@@ -110,7 +110,8 @@ object ChillDkgRitualManager {
|
||||
memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS
|
||||
|
||||
/**
|
||||
* Opens a ritual, making this device the coordinator.
|
||||
* Opens a ritual, making this device the coordinator. A room already running
|
||||
* one gets that one back rather than a second.
|
||||
*
|
||||
* Throws when the group or the threshold cannot support one; the UI checks
|
||||
* both before offering the button, so reaching either is a bug rather than a
|
||||
@@ -123,6 +124,26 @@ object ChillDkgRitualManager {
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
): DkgSession {
|
||||
// A room runs one ceremony at a time, and this is reachable twice now that
|
||||
// a robust group opens one as it is created: the room id is derived from
|
||||
// its members, so making the same group again lands back in the same room
|
||||
// and asks again. A second proposal is a second participant set for every
|
||||
// member to reconcile, and the key the first one produced would be left
|
||||
// with nothing pointing at it. A failed ritual is not running, and is
|
||||
// there to be replaced.
|
||||
//
|
||||
// Checked before the arguments are, because a running ritual makes the
|
||||
// requested threshold moot -- it settled that question when it opened.
|
||||
database.dkgSessionDao().getLatestSessionForChatRoom(localChatRoom.chatRoom.id)
|
||||
?.takeIf { it.stage != DkgRitualStage.FAILED }
|
||||
?.let { running ->
|
||||
logger.i(
|
||||
"Room ${localChatRoom.chatRoom.id} is already running ritual " +
|
||||
"${running.id}; not opening another"
|
||||
)
|
||||
return running
|
||||
}
|
||||
|
||||
// Built the way a receiver rebuilds it from the proposal — the p-tags
|
||||
// `broadcast` writes, plus this device — so both sides count the same `n`
|
||||
// even if the room's own rows have drifted.
|
||||
|
||||
+495
-154
@@ -7,6 +7,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
@@ -23,6 +24,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
|
||||
@@ -64,6 +66,20 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
* The path comes from the room, never from a proposal -- [signingPath] -- because
|
||||
* it decides which key the group signs as.
|
||||
*
|
||||
* ### It signs a batch
|
||||
*
|
||||
* A session carries one or more events as [FrostSigningItem] rows, and runs one
|
||||
* FROST instance per event in lockstep: one signer set, one aggregate per item,
|
||||
* one partial signature per item per signer, one approval. Four group events
|
||||
* whatever the size, instead of four per event.
|
||||
*
|
||||
* That is all the batching there is, and all there can be. A Schnorr partial
|
||||
* signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one
|
||||
* nonce give two equations in one unknown and the secret share falls out; a
|
||||
* batch shares only what is outside that equation. Every item has its own seed,
|
||||
* its own aggregate and its own `Session`, and [joinPayload] is the only place
|
||||
* they travel together.
|
||||
*
|
||||
* Three things are genuinely different from a ceremony, and each of them is why
|
||||
* this is a separate manager rather than another branch of that one.
|
||||
*
|
||||
@@ -79,8 +95,8 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
*
|
||||
* `SecretNonce` cannot be serialised and refuses to be used twice. Storing the
|
||||
* randomness it is derived from and regenerating on demand is the only way a
|
||||
* signing session can survive the app closing -- and it is safe only because a
|
||||
* session signs one message and cannot be made to sign another. See
|
||||
* signing session can survive the app closing -- and it is safe only because an
|
||||
* item signs one message and cannot be made to sign another. See
|
||||
* [FrostSigningSession] for the two rules that hold that in place; both are
|
||||
* enforced here, in [acceptProposal] and in [record].
|
||||
*
|
||||
@@ -88,14 +104,33 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
*
|
||||
* A DKG asks three times because each step publishes something different and
|
||||
* commits the member to something different. Here every step serves one
|
||||
* decision -- sign this event or do not -- and the event is fixed before the
|
||||
* member is asked. A second prompt would be the same question again.
|
||||
* decision -- sign these events or do not -- and they are fixed before the
|
||||
* member is asked. A second prompt would be the same question again. That holds
|
||||
* for a batch only as long as the member can see every event in it before
|
||||
* answering, which is the screen's side of the bargain.
|
||||
*/
|
||||
object FrostSigningManager {
|
||||
private const val TAG = "FrostSigningManager"
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
/**
|
||||
* The most events one session will sign.
|
||||
*
|
||||
* Enforced when proposing, and again -- independently -- when a proposal
|
||||
* arrives. The second check is the one that matters. A proposal is the only
|
||||
* place in this protocol where a remote party decides how much work everybody
|
||||
* else does: k native key generations, k signatures, and a group event
|
||||
* carrying k payloads, all from a single message. Until batching that was
|
||||
* bounded by never being more than one.
|
||||
*
|
||||
* Sized against what a group event will carry rather than picked round. A
|
||||
* signer's nonce message is k * 133 bytes of hex and commas and their partial
|
||||
* message k * 65, neither of which binds; the proposal carries k whole
|
||||
* events, which does.
|
||||
*/
|
||||
const val MAX_BATCH_SIZE: Int = 64
|
||||
|
||||
/**
|
||||
* Opens a signing session, making this device the coordinator.
|
||||
*
|
||||
@@ -123,7 +158,46 @@ object FrostSigningManager {
|
||||
content: String,
|
||||
key: DkgSession? = null,
|
||||
createdAt: Long = Clock.System.now().epochSeconds
|
||||
): FrostSigningSession = proposeSigningBatch(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
events = listOf(EventTemplate<Event>(createdAt, kind, tags, content)),
|
||||
key = key
|
||||
)
|
||||
|
||||
/**
|
||||
* Opens a session over several events at once, making this device the
|
||||
* coordinator.
|
||||
*
|
||||
* One ceremony, one signer set, one approval and four group events, whatever
|
||||
* the size -- but k independent FROST instances underneath, because that is
|
||||
* the only thing a batch can be. See [FrostSigningItem] for why.
|
||||
*
|
||||
* A batch is all-or-nothing: if any item cannot be aggregated the session
|
||||
* fails and none of its events are applied. That makes a batch **only as
|
||||
* available as its worst item**, so events that do not belong together
|
||||
* should not be proposed together.
|
||||
*
|
||||
* A failed batch is retried by proposing a *new* one, never by re-proposing
|
||||
* this session. Its items' nonce seeds have already been published against an
|
||||
* aggregate; reusing any of them for a second attempt would produce two
|
||||
* partial signatures over one secret nonce, which is how a share is
|
||||
* extracted. [itemsOver] mints fresh seeds precisely so that a retry is a new
|
||||
* session by construction.
|
||||
*/
|
||||
suspend fun proposeSigningBatch(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
events: List<EventTemplate<*>>,
|
||||
key: DkgSession? = null
|
||||
): FrostSigningSession {
|
||||
require(events.isNotEmpty()) { "A signing session must be given something to sign" }
|
||||
require(events.size <= MAX_BATCH_SIZE) {
|
||||
"A signing session will sign at most $MAX_BATCH_SIZE events, not ${events.size}"
|
||||
}
|
||||
|
||||
val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
|
||||
?: completedKey(database, localChatRoom.chatRoom.id)
|
||||
?: throw IllegalStateException("This group has no shared key to sign with")
|
||||
@@ -132,7 +206,16 @@ object FrostSigningManager {
|
||||
?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}")
|
||||
|
||||
val path = signingPath(database, localChatRoom, ceremony)
|
||||
val unsignedEvent = unsignedEventOf(ceremony, path, kind, tags, content, createdAt)
|
||||
val unsignedEvents = events.map { template ->
|
||||
unsignedEventOf(
|
||||
key = ceremony,
|
||||
path = path,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
createdAt = template.createdAt
|
||||
)
|
||||
}
|
||||
val sessionId = RandomInstance.bytes(32).toHex()
|
||||
|
||||
val session = FrostSigningSession(
|
||||
@@ -145,25 +228,24 @@ 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, unsignedEvents))
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}")
|
||||
logger.i(
|
||||
"Proposing signature $sessionId over ${describe(unsignedEvents.size)} " +
|
||||
"(${unsignedEvents.joinToString { it.id.take(8) }}) with key ${ceremony.id}"
|
||||
)
|
||||
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = FrostSigningEvents.PROPOSAL,
|
||||
content = session.unsignedEventJson,
|
||||
content = FrostSigningEvents.encodeProposal(unsignedEvents),
|
||||
includeKey = true
|
||||
)
|
||||
|
||||
@@ -236,15 +318,17 @@ 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.
|
||||
val proposed = Event.fromJsonOrNull(innerEvent.content)
|
||||
if (proposed != null && proposed.id != existing.eventId) {
|
||||
// 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 = FrostSigningEvents.decodeProposal(innerEvent.content)
|
||||
val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId }
|
||||
if (proposed != null && signing != proposed.map { it.id }) {
|
||||
logger.w(
|
||||
"Session $sessionId re-proposed with event ${proposed.id}, " +
|
||||
"but it is already signing ${existing.eventId}; ignoring"
|
||||
"Session $sessionId re-proposed with ${describe(proposed.size)} " +
|
||||
"(${proposed.joinToString { it.id.take(8) }}), but it is already signing " +
|
||||
"${signing.joinToString { it.take(8) }}; ignoring"
|
||||
)
|
||||
}
|
||||
return existing
|
||||
@@ -268,9 +352,19 @@ object FrostSigningManager {
|
||||
return null
|
||||
}
|
||||
|
||||
val proposed = Event.fromJsonOrNull(innerEvent.content)
|
||||
val proposed = FrostSigningEvents.decodeProposal(innerEvent.content)
|
||||
if (proposed == null) {
|
||||
logger.w("Signing proposal $sessionId does not carry an event; dropping")
|
||||
logger.w("Signing proposal $sessionId does not carry any events; dropping")
|
||||
return null
|
||||
}
|
||||
|
||||
// Checked here as well as when proposing, because this is where a remote
|
||||
// party gets to decide how much work this device does. See [MAX_BATCH_SIZE].
|
||||
if (proposed.size > MAX_BATCH_SIZE) {
|
||||
logger.w(
|
||||
"Signing proposal $sessionId asks for ${proposed.size} events, " +
|
||||
"more than the $MAX_BATCH_SIZE this device will sign at once; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -284,20 +378,29 @@ object FrostSigningManager {
|
||||
// could choose the key the group signs as, and every signer would put
|
||||
// their share behind an author none of them checked.
|
||||
val path = signingPath(database, localChatRoom, key)
|
||||
val unsignedEvent = unsignedEventOf(
|
||||
key = key,
|
||||
path = path,
|
||||
kind = proposed.kind,
|
||||
tags = proposed.tags,
|
||||
content = proposed.content,
|
||||
createdAt = proposed.createdAt
|
||||
)
|
||||
if (unsignedEvent.id != proposed.id) {
|
||||
logger.w(
|
||||
"Signing proposal $sessionId carries id ${proposed.id} but its fields hash " +
|
||||
"to ${unsignedEvent.id}; dropping"
|
||||
val unsignedEvents = proposed.map { event ->
|
||||
unsignedEventOf(
|
||||
key = key,
|
||||
path = path,
|
||||
kind = event.kind,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
createdAt = event.createdAt
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// All of them or none. A batch's length is what every later payload is
|
||||
// checked against, so quietly dropping one bad event would leave a session
|
||||
// that rejects every signer's contribution for being the wrong size --
|
||||
// a stall with nothing to blame it on.
|
||||
unsignedEvents.forEachIndexed { index, rebuilt ->
|
||||
if (rebuilt.id != proposed[index].id) {
|
||||
logger.w(
|
||||
"Signing proposal $sessionId carries id ${proposed[index].id} at $index " +
|
||||
"but its fields hash to ${rebuilt.id}; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
val session = FrostSigningSession(
|
||||
@@ -312,15 +415,16 @@ 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, unsignedEvents))
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval")
|
||||
logger.i(
|
||||
"Recorded signing session $sessionId over ${describe(unsignedEvents.size)} " +
|
||||
"(${unsignedEvents.joinToString { it.id.take(8) }}); awaiting approval"
|
||||
)
|
||||
|
||||
announceApprovalNeeded(database, session)
|
||||
replayStoredMessages(database, session)
|
||||
@@ -406,26 +510,22 @@ 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) {
|
||||
val aggregated = splitForSession(database, session, innerEvent.content)
|
||||
?: return true
|
||||
|
||||
if (applyAggregate(database, session, signerIds, aggregated)) {
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
@@ -433,17 +533,15 @@ 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
|
||||
}
|
||||
}
|
||||
val signatures = splitForSession(database, session, innerEvent.content)
|
||||
?: return true
|
||||
|
||||
if (!known) {
|
||||
if (applySignatures(database, session, signatures)) {
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
@@ -504,7 +602,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,51 +624,77 @@ object FrostSigningManager {
|
||||
val tweakCache = SharedKeyDerivation
|
||||
.derive(thresholdPublicKey, session.pathIndices())
|
||||
.cache
|
||||
val message = ByteVector(session.eventId.hexToByteArray())
|
||||
val publicShares = key.publicShareList()
|
||||
|
||||
// Regenerated rather than stored -- SecretNonce refuses both. Safe
|
||||
// because the session's message can never change; see the notes on
|
||||
// FrostSigningSession.
|
||||
val (secretNonce, publicNonce) = SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(session.nonceRandom),
|
||||
secretShare = secretShare,
|
||||
publicShare = publicShares?.getOrNull(session.signerId),
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
// The events this session signs, in the order the proposal fixed. Every
|
||||
// join below is positional against it.
|
||||
val items = database.frostSigningSessionDao().getItems(sessionId)
|
||||
if (items.isEmpty()) return
|
||||
|
||||
if (ownMessage(database, session, FrostSigningEvents.NONCE) == null) {
|
||||
val messages = items.map { ByteVector(it.eventId.hexToByteArray()) }
|
||||
|
||||
val ownNonces = ownMessage(database, session, FrostSigningEvents.NONCE)
|
||||
val ownPartials = ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE)
|
||||
|
||||
// One nonce per item, regenerated rather than stored -- SecretNonce
|
||||
// refuses both. Safe because an item's message can never change; see
|
||||
// the notes on FrostSigningSession.
|
||||
//
|
||||
// On demand rather than up front, and that is the point of the `lazy`:
|
||||
// `advance` runs on every arriving message, so at a batch of k this is
|
||||
// k native key generations each time, usually to find there is nothing
|
||||
// left to publish. A guard computed here instead would have to predict
|
||||
// whether this device turns out to be a signer, which it cannot -- the
|
||||
// coordinator settles the signer set further down this same pass.
|
||||
val nonces by lazy {
|
||||
items.mapIndexed { index, item ->
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(item.nonceRandom),
|
||||
secretShare = secretShare,
|
||||
publicShare = publicShares?.getOrNull(session.signerId),
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = messages[index],
|
||||
extraInput = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (ownNonces == null) {
|
||||
publishOwn(
|
||||
database,
|
||||
localChatRoom,
|
||||
session,
|
||||
FrostSigningEvents.NONCE,
|
||||
publicNonce.data.toHex()
|
||||
joinPayload(nonces.map { (_, publicNonce) -> publicNonce.data.toHex() })
|
||||
)
|
||||
}
|
||||
|
||||
if (session.isCoordinator() && session.aggregatedNonce == null) {
|
||||
val offered = orderedNonces(database, session) ?: return
|
||||
if (session.isCoordinator() && session.signerIds == null) {
|
||||
val offered = orderedNonces(database, session, items.size) ?: return
|
||||
val chosen = offered.take(session.threshold)
|
||||
val chosenIds = chosen.map { (id, _) -> id }
|
||||
|
||||
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() }
|
||||
)
|
||||
// One aggregate per item, each built from that item's nonce from
|
||||
// each chosen signer. Reusing one across two items would be reusing
|
||||
// R across two messages, which is the whole thing this design is
|
||||
// arranged to make impossible.
|
||||
val aggregated = items.indices.map { index ->
|
||||
IndividualNonce.aggregate(chosen.map { (_, offeredNonces) -> offeredNonces[index] })
|
||||
.orThrow("aggregating nonces")
|
||||
.toByteArray()
|
||||
.toHex()
|
||||
}
|
||||
|
||||
if (!applyAggregate(database, session, chosenIds, aggregated)) 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 }
|
||||
content = joinPayload(aggregated),
|
||||
signerIds = chosenIds
|
||||
)
|
||||
announceStep(
|
||||
database,
|
||||
@@ -580,54 +704,79 @@ object FrostSigningManager {
|
||||
)
|
||||
}
|
||||
|
||||
val aggregatedNonce = session.aggregatedNonce ?: return
|
||||
val signerIds = session.signerIdList() ?: return
|
||||
// Re-read, because the aggregate above is written to the item rows.
|
||||
val aggregated = database.frostSigningSessionDao().getItems(sessionId)
|
||||
session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES)
|
||||
|
||||
val signingSession = Session.create(
|
||||
aggregatedNonce = AggregatedNonce(aggregatedNonce.hexToByteArray()),
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = publicShares?.let { shares ->
|
||||
signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size }
|
||||
},
|
||||
nParticipants = session.participantCount,
|
||||
threshold = session.threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
|
||||
// A member outside the chosen set has nothing to contribute and is not
|
||||
// holding anybody up. They stay in the session to receive the finished
|
||||
// signature like everybody else.
|
||||
if (session.isSigner() && ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) == null) {
|
||||
val partialSignature = signingSession
|
||||
.sign(secretNonce, secretShare, session.signerId.toUInt())
|
||||
.orThrow("signing")
|
||||
// signatures like everybody else -- and leaving here rather than
|
||||
// building k FROST sessions to do nothing with is the same saving the
|
||||
// nonce short-circuit above makes.
|
||||
val signing = session.isSigner() && ownPartials == null
|
||||
val aggregating = session.isCoordinator() && !isSigned(database, session)
|
||||
if (!signing && !aggregating) {
|
||||
complete(database, session)
|
||||
return
|
||||
}
|
||||
|
||||
// One FROST session per item. `Session.create` binds the message and
|
||||
// the aggregate together, so only the signer set, the shares and the
|
||||
// tweak cache are shared across a batch.
|
||||
val signingSessions = aggregated.mapIndexed { index, item ->
|
||||
Session.create(
|
||||
aggregatedNonce = AggregatedNonce(
|
||||
(item.aggregatedNonce ?: return).hexToByteArray()
|
||||
),
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = publicShares?.let { shares ->
|
||||
signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size }
|
||||
},
|
||||
nParticipants = session.participantCount,
|
||||
threshold = session.threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = messages[index]
|
||||
)
|
||||
}
|
||||
|
||||
if (signing) {
|
||||
val partialSignatures = signingSessions.mapIndexed { index, signingSession ->
|
||||
signingSession
|
||||
.sign(nonces[index].first, secretShare, session.signerId.toUInt())
|
||||
.orThrow("signing")
|
||||
.toHex()
|
||||
}
|
||||
|
||||
publishOwn(
|
||||
database,
|
||||
localChatRoom,
|
||||
session,
|
||||
FrostSigningEvents.PARTIAL_SIGNATURE,
|
||||
partialSignature.toHex()
|
||||
joinPayload(partialSignatures)
|
||||
)
|
||||
}
|
||||
|
||||
if (session.isCoordinator() && session.signature == null) {
|
||||
val partials = orderedPartialSignatures(database, session, signerIds) ?: return
|
||||
if (aggregating) {
|
||||
val partials = orderedPartialSignatures(database, session, signerIds, aggregated.size)
|
||||
?: return
|
||||
|
||||
val signature = signingSession
|
||||
.aggregateSigs(partials.map { ByteVector32(it) })
|
||||
.orThrow("aggregating partial signatures")
|
||||
.toHex()
|
||||
val signatures = signingSessions.mapIndexed { index, signingSession ->
|
||||
signingSession
|
||||
.aggregateSigs(partials.map { ByteVector32(it[index]) })
|
||||
.orThrow("aggregating partial signatures")
|
||||
.toHex()
|
||||
}
|
||||
|
||||
if (!applySignatures(database, session, signatures)) return
|
||||
session = current(database, session)
|
||||
|
||||
session = update(database, session) { it.copy(signature = signature) }
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = FrostSigningEvents.SIGNATURE,
|
||||
content = signature
|
||||
content = joinPayload(signatures)
|
||||
)
|
||||
announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey)
|
||||
}
|
||||
@@ -662,16 +811,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 +841,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 +894,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 +908,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
|
||||
@@ -765,17 +1029,25 @@ object FrostSigningManager {
|
||||
*/
|
||||
private suspend fun orderedNonces(
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession
|
||||
): List<Pair<Int, IndividualNonce>>? {
|
||||
session: FrostSigningSession,
|
||||
items: Int
|
||||
): List<Pair<Int, List<IndividualNonce>>>? {
|
||||
val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null
|
||||
val idByMember = signerIds(database, key)
|
||||
|
||||
val offered = database.frostSigningSessionDao()
|
||||
.getMessagesByKind(session.id, FrostSigningEvents.NONCE)
|
||||
.mapNotNull { message ->
|
||||
idByMember[message.signerPublicKey]?.let { id ->
|
||||
id to IndividualNonce(message.payload.hexToByteArray())
|
||||
val id = idByMember[message.signerPublicKey] ?: return@mapNotNull null
|
||||
val values = splitPayload(message.payload, items) ?: run {
|
||||
logger.w(
|
||||
"Session ${session.id}: ${message.signerPublicKey.take(8)} offered a " +
|
||||
"nonce payload that is not $items value(s); leaving them out"
|
||||
)
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
id to values.map { IndividualNonce(it.hexToByteArray()) }
|
||||
}
|
||||
.sortedBy { (id, _) -> id }
|
||||
|
||||
@@ -795,8 +1067,9 @@ object FrostSigningManager {
|
||||
private suspend fun orderedPartialSignatures(
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession,
|
||||
signerIds: List<Int>
|
||||
): List<ByteArray>? {
|
||||
signerIds: List<Int>,
|
||||
items: Int
|
||||
): List<List<ByteArray>>? {
|
||||
val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null
|
||||
val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member }
|
||||
|
||||
@@ -805,7 +1078,9 @@ object FrostSigningManager {
|
||||
.associate { it.signerPublicKey to it.payload }
|
||||
|
||||
val partials = signerIds.mapNotNull { id ->
|
||||
memberById[id]?.let { payloadByMember[it] }
|
||||
val payload = memberById[id]?.let { payloadByMember[it] } ?: return@mapNotNull null
|
||||
|
||||
splitPayload(payload, items)?.map { it.hexToByteArray() }
|
||||
}
|
||||
|
||||
if (partials.size < signerIds.size) {
|
||||
@@ -813,7 +1088,50 @@ object FrostSigningManager {
|
||||
return null
|
||||
}
|
||||
|
||||
return partials.map { it.hexToByteArray() }
|
||||
return partials
|
||||
}
|
||||
|
||||
/**
|
||||
* A signer's whole contribution to a batch, as one payload.
|
||||
*
|
||||
* Comma separated, the encoding [FrostSigningSession.signerIds] already uses,
|
||||
* and at a batch of one it is the bare value — which is what keeps a
|
||||
* single-event session on exactly the wire it has always been on.
|
||||
*
|
||||
* One row per signer per kind rather than one per item, deliberately.
|
||||
* [FrostSignerMessage]'s key is what makes a redelivered message replace its
|
||||
* predecessor instead of adding a row, and splitting by item would multiply
|
||||
* the ways a partial delivery can look like a complete one.
|
||||
*/
|
||||
private fun joinPayload(values: List<HexKey>): String = values.joinToString(",")
|
||||
|
||||
/**
|
||||
* The other half, and strict: a payload not carrying exactly [expected]
|
||||
* values is refused rather than truncated or padded.
|
||||
*
|
||||
* Checked here rather than in [record] on purpose. Payloads are stored
|
||||
* without being parsed, which is what lets a nonce arrive before the proposal
|
||||
* that would give it a length to be checked against. This runs where the
|
||||
* session — and so the length — is known.
|
||||
*/
|
||||
private fun splitPayload(payload: String, expected: Int): List<HexKey>? =
|
||||
payload.split(",").map { it.trim() }.takeIf { it.size == expected }
|
||||
|
||||
/** [splitPayload] against the number of events this session signs. */
|
||||
private suspend fun splitForSession(
|
||||
database: MantraDatabase,
|
||||
session: FrostSigningSession,
|
||||
payload: String
|
||||
): List<HexKey>? {
|
||||
val expected = database.frostSigningSessionDao().countItems(session.id)
|
||||
|
||||
return splitPayload(payload, expected) ?: run {
|
||||
logger.w(
|
||||
"Session ${session.id}: payload carries ${payload.split(",").size} value(s) " +
|
||||
"for $expected item(s); ignoring"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1091,7 +1409,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 +1421,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 +1439,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 +1467,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,
|
||||
@@ -1163,15 +1486,20 @@ object FrostSigningManager {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) =
|
||||
private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) {
|
||||
val count = database.frostSigningSessionDao().countItems(session.id)
|
||||
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_FROST_STARTED,
|
||||
content = "asked the group to sign something with its shared key. It takes " +
|
||||
"${session.threshold} of ${session.participantCount} members to do it.",
|
||||
content = "asked the group to sign " +
|
||||
(if (count > 1) "$count events" else "something") +
|
||||
" with its shared key. It takes ${session.threshold} of " +
|
||||
"${session.participantCount} members to do it.",
|
||||
actor = session.coordinatorPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the group's chat that this session is waiting on the reader.
|
||||
@@ -1188,12 +1516,15 @@ object FrostSigningManager {
|
||||
|
||||
update(database, session) { it.copy(approvalRequestedAt = Clock.System.now()) }
|
||||
|
||||
val count = database.frostSigningSessionDao().countItems(session.id)
|
||||
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_FROST_APPROVAL_NEEDED,
|
||||
content = "Your approval is needed to sign with the group's shared key. Nothing " +
|
||||
"has been published from this device yet.",
|
||||
content = "Your approval is needed to sign " +
|
||||
(if (count > 1) "$count events " else "") +
|
||||
"with the group's shared key. Nothing has been published from this device yet.",
|
||||
actor = session.userPublicKey
|
||||
)
|
||||
}
|
||||
@@ -1212,19 +1543,29 @@ object FrostSigningManager {
|
||||
kind: Kind,
|
||||
actor: HexKey
|
||||
) {
|
||||
// A batch is still one line per member per step -- what changes is the
|
||||
// number in it. Every one of these lines describes work that covered the
|
||||
// whole batch, so saying so is the difference between a member reading
|
||||
// "signed their part" and knowing what they signed.
|
||||
val count = database.frostSigningSessionDao().countItems(session.id)
|
||||
val batch = count > 1
|
||||
|
||||
val (messageType, content) = when (kind) {
|
||||
FrostSigningEvents.NONCE -> ChatMessage.TYPE_FROST_NONCE to
|
||||
"offered to help sign, sending the one-time value their signature needs."
|
||||
"offered to help sign, sending the one-time " +
|
||||
(if (batch) "values their signatures need." else "value their signature needs.")
|
||||
|
||||
FrostSigningEvents.SIGNER_SET -> ChatMessage.TYPE_FROST_SIGNER_SET to
|
||||
"chose who is signing and combined their one-time values."
|
||||
|
||||
FrostSigningEvents.PARTIAL_SIGNATURE -> ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE to
|
||||
"signed their part. On its own it proves nothing; combined with the " +
|
||||
"others it is the group's signature."
|
||||
"signed their part" + (if (batch) " of all $count events" else "") +
|
||||
". On its own it proves nothing; combined with the others it is " +
|
||||
"the group's " + (if (batch) "signatures." else "signature.")
|
||||
|
||||
FrostSigningEvents.SIGNATURE -> ChatMessage.TYPE_FROST_SIGNATURE to
|
||||
"combined the parts into the group's signature."
|
||||
"combined the parts into the group's " +
|
||||
(if (batch) "$count signatures." else "signature.")
|
||||
|
||||
// PROPOSAL and FAILURE are announced by the code that acts on them --
|
||||
// both say more than the message itself carries.
|
||||
|
||||
@@ -72,6 +72,12 @@ object GroupKeyStateManager {
|
||||
* [key] is handed to the signing session rather than looked up from the
|
||||
* room, because the room has no key state yet and looking one up is exactly
|
||||
* what this session exists to make possible.
|
||||
*
|
||||
* A session of one, always, and never batched with anything else. A batch is
|
||||
* all-or-nothing, so it is only as available as its worst item -- and this is
|
||||
* the statement every other session in the room is opened against. Bundling
|
||||
* it with a dialect would make the room's ability to sign at all depend on
|
||||
* that dialect's aggregation succeeding.
|
||||
*/
|
||||
suspend fun propose(
|
||||
database: MantraDatabase,
|
||||
|
||||
+47
-2
@@ -1,6 +1,9 @@
|
||||
package press.mantra.compose.nostr.frost
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import press.mantra.compose.network.serialization.CommonJson
|
||||
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
|
||||
@@ -47,8 +50,9 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
|
||||
*/
|
||||
object FrostSigningEvents {
|
||||
/**
|
||||
* Opens a session. Content is the unsigned nostr event, as JSON; the key to
|
||||
* sign with is named in [FrostKeyTag].
|
||||
* Opens a session. Content is the unsigned nostr event -- or, for a batch,
|
||||
* the array of them -- as JSON; the key to sign with is named in [FrostKeyTag].
|
||||
* See [encodeProposal] for why the two forms are not one.
|
||||
*/
|
||||
val PROPOSAL: Kind = 30320
|
||||
|
||||
@@ -104,4 +108,45 @@ object FrostSigningEvents {
|
||||
|
||||
fun parseSignerIds(tags: Array<Array<String>>): List<Int>? =
|
||||
tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds
|
||||
|
||||
/**
|
||||
* The unsigned events of a proposal, as its content.
|
||||
*
|
||||
* A batch of one serialises as the bare event object it always did, and only
|
||||
* a genuine batch becomes an array. That is not tidiness. A build predating
|
||||
* batching reads an array with `Event.fromJsonOrNull`, gets null, and drops
|
||||
* the proposal -- so an old device refuses a batch outright rather than
|
||||
* signing part of one, while single signing keeps working right through a
|
||||
* mixed-version rollout. Emitting an array unconditionally would break every
|
||||
* one-event session for those devices and buy nothing.
|
||||
*
|
||||
* An empty list has no honest encoding and is not one, so callers do not
|
||||
* produce it and [decodeProposal] does not accept it.
|
||||
*/
|
||||
fun encodeProposal(events: List<Event>): String =
|
||||
events.singleOrNull()?.toJson()
|
||||
?: events.joinToString(separator = ",", prefix = "[", postfix = "]") { it.toJson() }
|
||||
|
||||
/**
|
||||
* The other half. Both forms are accepted, permanently: proposals in the old
|
||||
* shape do not stop arriving just because this build stopped writing them.
|
||||
*
|
||||
* All-or-nothing. An array with one unreadable element is refused rather than
|
||||
* silently shortened, because the batch's length is what every later payload
|
||||
* is checked against -- a proposal that quietly lost an event would have every
|
||||
* signer's contribution rejected for being the wrong size, which is a stall
|
||||
* with no message to blame.
|
||||
*/
|
||||
fun decodeProposal(content: String): List<Event>? {
|
||||
if (!content.trimStart().startsWith("[")) {
|
||||
return Event.fromJsonOrNull(content)?.let { listOf(it) }
|
||||
}
|
||||
|
||||
val elements = runCatching { CommonJson.parseToJsonElement(content).jsonArray }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
|
||||
return elements.map { element -> Event.fromJsonOrNull(element.toString()) ?: return null }
|
||||
}
|
||||
}
|
||||
+54
-3
@@ -3,9 +3,11 @@ package press.mantra.compose.repository
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
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 +26,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?
|
||||
|
||||
/**
|
||||
@@ -52,14 +65,41 @@ interface FrostSigningRepository {
|
||||
content: String
|
||||
): FrostSigningSession?
|
||||
|
||||
/**
|
||||
* Opens one session over several events, so a group answers once instead of
|
||||
* once per event.
|
||||
*
|
||||
* Four group events and one approval whatever the size, but k independent
|
||||
* FROST instances underneath -- there is no such thing as one signature over
|
||||
* k messages, and no way to share a nonce between two of them. See
|
||||
* [FrostSigningItem].
|
||||
*
|
||||
* Two things a caller has to decide before reaching for this.
|
||||
*
|
||||
* **A batch is only as available as its worst item.** It is all-or-nothing:
|
||||
* if any event cannot be aggregated the session fails and none of them are
|
||||
* applied. Events that do not belong together should not travel together.
|
||||
*
|
||||
* **A retry is a new batch, never this one again.** Its items' nonce seeds
|
||||
* have already been published against an aggregate, and reusing one would
|
||||
* produce two partial signatures over a single secret nonce -- which is how
|
||||
* a share is extracted. Propose afresh; the manager mints new seeds by
|
||||
* construction.
|
||||
*/
|
||||
suspend fun proposeSigningBatch(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
events: List<EventTemplate<*>>
|
||||
): FrostSigningSession?
|
||||
|
||||
/** Agrees to sign, letting the session publish this device's part and run on. */
|
||||
suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String)
|
||||
|
||||
/** 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 +111,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
|
||||
@@ -85,11 +130,17 @@ interface FrostSigningRepository {
|
||||
content: String
|
||||
): FrostSigningSession? = null
|
||||
|
||||
override suspend fun proposeSigningBatch(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
events: List<EventTemplate<*>>
|
||||
): FrostSigningSession? = null
|
||||
|
||||
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
-16
@@ -139,15 +139,25 @@ fun FrostSigningScreen(
|
||||
// flash an error on the way in.
|
||||
val session = state.session ?: return@Scaffold Loading(padding)
|
||||
|
||||
val proposed = frostSigningViewModel.proposedEvents(state.items)
|
||||
|
||||
// Every event of the batch has to be readable before any of it can
|
||||
// be signed. A member cannot check what they cannot see, and a
|
||||
// batch is all-or-nothing: agreeing to the two that rendered would
|
||||
// be agreeing to the third as well.
|
||||
val readable = state.items.isNotEmpty() && proposed.size == state.items.size
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scrollState)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp)
|
||||
) {
|
||||
WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session))
|
||||
WhatIsBeingSigned(proposed, state.items.size)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
@@ -230,22 +240,43 @@ 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(
|
||||
text = "Nothing has been published from this device yet. Signing " +
|
||||
"puts your share behind this event; it cannot be taken back.",
|
||||
"puts your share behind " +
|
||||
(if (proposed.size > 1) "all ${proposed.size} of these events" else "this event") +
|
||||
"; it cannot be taken back.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
// A batch can hide an event below the fold in a way one
|
||||
// event cannot: what is off-screen is not further detail
|
||||
// about the thing on screen, it is a different thing the
|
||||
// member would also be signing. So a batch's Sign button
|
||||
// waits until the list has been read to the end. maxValue
|
||||
// is Int.MAX_VALUE until the first layout, and 0 when
|
||||
// everything already fits.
|
||||
val seenEverything = proposed.size <= 1 ||
|
||||
(scrollState.maxValue != Int.MAX_VALUE && scrollState.value >= scrollState.maxValue)
|
||||
|
||||
if (readable && !seenEverything) {
|
||||
Text(
|
||||
text = "Read to the end of the list to sign.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
enabled = !frostSigningViewModel.isActionPending.value,
|
||||
enabled = readable && seenEverything &&
|
||||
!frostSigningViewModel.isActionPending.value,
|
||||
onClick = { frostSigningViewModel.approve(onNavigateBack) }
|
||||
) {
|
||||
Icon(Icons.Default.Draw, contentDescription = null)
|
||||
@@ -253,6 +284,11 @@ fun FrostSigningScreen(
|
||||
Text("Sign")
|
||||
}
|
||||
|
||||
// Declining stays available whatever the screen could
|
||||
// not show. A member who cannot check what they are
|
||||
// being asked to sign should still be able to say no,
|
||||
// and saying nothing is indistinguishable from a phone
|
||||
// in a pocket -- which leaves the group waiting.
|
||||
TextButton(
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
@@ -290,16 +326,48 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
|
||||
* refusing to describe an event is better than describing it wrongly.
|
||||
*/
|
||||
@Composable
|
||||
private fun WhatIsBeingSigned(event: Event?) {
|
||||
if (event == null) {
|
||||
private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
|
||||
if (expected == 0 || events.size != expected) {
|
||||
Text(
|
||||
text = "This session's event could not be read, so there is nothing to check " +
|
||||
"before signing. Don't sign it.",
|
||||
text = if (expected <= 1) {
|
||||
"This session's event could not be read, so there is nothing to check " +
|
||||
"before signing. Don't sign it."
|
||||
} else {
|
||||
"Only ${events.size} of this session's $expected events could be read, so " +
|
||||
"there is no way to check what you would be signing. Don't sign it."
|
||||
},
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(15.dp)) {
|
||||
if (events.size > 1) {
|
||||
Text(
|
||||
text = "${events.size} events, signed together",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
events.forEachIndexed { index, event ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
|
||||
OneThingBeingSigned(event)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Signed by the group, not by you. Once enough members sign, " +
|
||||
(if (events.size > 1) "these are" else "this is") +
|
||||
" published under the group's shared key.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One event of the batch, described as the thing it is. */
|
||||
@Composable
|
||||
private fun OneThingBeingSigned(event: Event) {
|
||||
val (label, detail) = when (event.kind) {
|
||||
DialectEvent.KIND -> "New dialect" to DialectEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
@@ -351,13 +419,6 @@ private fun WhatIsBeingSigned(event: Event?) {
|
||||
Text(text = label, style = MaterialTheme.typography.labelMedium)
|
||||
|
||||
Text(text = detail, style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Text(
|
||||
text = "Signed by the group, not by you. Once enough members sign, this is " +
|
||||
"published under the group's shared key.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
@@ -70,6 +71,7 @@ fun SelectChatRoomTypeScreen(
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
) {
|
||||
val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel(
|
||||
@@ -81,7 +83,8 @@ fun SelectChatRoomTypeScreen(
|
||||
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
)
|
||||
|
||||
@@ -104,6 +107,7 @@ fun SelectChatRoomTypeScreen(
|
||||
val isActionPending = selectChatRoomTypeViewModel.isActionPending.value
|
||||
val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value
|
||||
val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded
|
||||
val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value
|
||||
val adminCount = selectChatRoomTypeViewModel.adminCount
|
||||
val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable
|
||||
val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST
|
||||
@@ -188,6 +192,22 @@ fun SelectChatRoomTypeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCeremonyNotStarted) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "This decides who can change the group later. You can't switch afterwards.",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@@ -216,7 +236,11 @@ fun SelectChatRoomTypeScreen(
|
||||
} else {
|
||||
"Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect."
|
||||
},
|
||||
footnote = "No single admin can change the group alone, and the group outlives any one of you.",
|
||||
// Says what tapping create actually does, because it is the one
|
||||
// thing here that asks something of everybody else: the group's
|
||||
// first act is a ceremony each member has to approve their way
|
||||
// through before there is a key.
|
||||
footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.",
|
||||
isSelected = isRobustSelected,
|
||||
isEnabled = isRobustAvailable,
|
||||
disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason,
|
||||
@@ -447,6 +471,7 @@ private fun SelectChatRoomTypeScreenPreview() {
|
||||
activeWalletStateFlow = MutableStateFlow(null),
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -543,6 +543,7 @@ fun MantraNavHost(
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateToRoute = { chatRoomResultRoute ->
|
||||
navController.navigate(
|
||||
chatRoomResultRoute
|
||||
|
||||
+11
-8
@@ -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
|
||||
|
||||
+65
-14
@@ -15,6 +15,7 @@ import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
@@ -59,6 +60,7 @@ class SelectChatRoomTypeViewModel(
|
||||
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
val nostrRepository: NostrRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val dkgRepository: DkgRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState)
|
||||
@@ -81,6 +83,15 @@ class SelectChatRoomTypeViewModel(
|
||||
/** Members the group was created without, because no key package ever showed up. */
|
||||
val membersNotAdded = mutableStateListOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Set when a [ChatRoomType.ROBUST] room was made but its key ceremony was not.
|
||||
*
|
||||
* The room is real and usable either way -- only the shared key is missing, and
|
||||
* it can be started again from the group's details. Worth saying rather than
|
||||
* navigating on, because the key is the whole of what robust means.
|
||||
*/
|
||||
val keyCeremonyNotStarted: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Everyone who administers the group under [ChatRoomType.ROBUST]: the picked
|
||||
* members plus the creator.
|
||||
@@ -188,13 +199,16 @@ class SelectChatRoomTypeViewModel(
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
val nostrPrivateKeyBytes = nostrPrivateKey.value.toByteArray()
|
||||
val keyPair = KeyPair(
|
||||
privKey = nostrPrivateKey.value.toByteArray()
|
||||
privKey = nostrPrivateKeyBytes
|
||||
)
|
||||
|
||||
when (selectedChatRoomType.value) {
|
||||
ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute)
|
||||
ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute)
|
||||
// The ceremony needs the secret itself, not the pair: the ChillDKG host
|
||||
// key is derived from it rather than being it.
|
||||
ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, nostrPrivateKeyBytes, onNavigateToRoute)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,13 +301,23 @@ class SelectChatRoomTypeViewModel(
|
||||
* invite anyone to and no key package to wait on — everybody picked is in the room
|
||||
* the moment it exists, and learns about it from the first message.
|
||||
*
|
||||
* TODO: the quorum the user picked has nowhere to live here. NIP-17 has no group
|
||||
* state to change and so nothing to approve — the member set is whatever a message
|
||||
* is addressed to, and a different set is simply a different room. Enforcing t-of-n
|
||||
* needs a governance layer this protocol does not have.
|
||||
* That first message is the key ceremony, opened here rather than left for somebody
|
||||
* to find a button for.
|
||||
*
|
||||
* NIP-17 itself has nothing for the quorum to govern — no group state to change, and
|
||||
* a different member set is simply a different room — so the only thing that can
|
||||
* carry it is a key the group generates together and can only sign with t of n of
|
||||
* them present. Everything that ceremony needs is settled by the time the room
|
||||
* exists: who is in it, and how many of them have to agree. Waiting would mean
|
||||
* handing the user the room they asked for minus the thing that makes it robust.
|
||||
*
|
||||
* Opening it is also how the rest of the group hears of the room at all. Standing up
|
||||
* a NIP-17 room sends nothing to anybody; the proposal is the first event out, and
|
||||
* the inbound path builds the same room on the other side from its p-tags.
|
||||
*/
|
||||
private fun createNip17ChatRoom(
|
||||
keyPair: KeyPair,
|
||||
nostrPrivateKey: ByteArray,
|
||||
onNavigateToRoute: (Route) -> Unit
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -304,18 +328,43 @@ class SelectChatRoomTypeViewModel(
|
||||
description = description
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isActionPending.value = false
|
||||
|
||||
if (localChatRoom == null) {
|
||||
if (localChatRoom == null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
isActionPending.value = false
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Something went wrong")
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
createdChatRoomId.value = localChatRoom.chatRoom.id
|
||||
|
||||
// The room's membership is the set this screen was opened for, so the
|
||||
// quorum picked against [adminCount] is the same t-of-n the ceremony is
|
||||
// asked for. A room that already has a ceremony -- the id is derived from
|
||||
// its members, so making the same group twice returns the same room --
|
||||
// hands that one back instead of opening a second.
|
||||
val session = dkgRepository.proposeRitual(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = keyPair.pubKey.toHexKey(),
|
||||
nostrPrivateKey = nostrPrivateKey,
|
||||
threshold = quorum.value
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isActionPending.value = false
|
||||
|
||||
if (session == null) {
|
||||
// Nothing is rolled back: the room works, the group can talk in it,
|
||||
// and the ceremony can be opened again from the group's details.
|
||||
// Said here rather than navigated past, because a robust group
|
||||
// without a shared key is not what the user asked for.
|
||||
logger.e("Created ${localChatRoom.chatRoom.id} without a key ceremony")
|
||||
keyCeremonyNotStarted.value = true
|
||||
return@withContext
|
||||
}
|
||||
|
||||
createdChatRoomId.value = localChatRoom.chatRoom.id
|
||||
|
||||
onNavigateToRoute.invoke(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = keyPair.pubKey.toHexKey(),
|
||||
@@ -393,7 +442,8 @@ class SelectChatRoomTypeViewModel(
|
||||
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
SelectChatRoomTypeViewModel(
|
||||
@@ -404,7 +454,8 @@ class SelectChatRoomTypeViewModel(
|
||||
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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(),
|
||||
|
||||
|
||||
+149
-21
@@ -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
|
||||
@@ -159,6 +160,105 @@ class FrostSigningRoundTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one signer set signs a batch of three, and every signature verifies`() {
|
||||
// The manager's batch, with the database taken out of the way: one signer
|
||||
// set and one tweak cache shared, and a nonce, a Session and a signature
|
||||
// per event. If any of that is wired up wrongly the aggregate simply
|
||||
// fails to verify, which is the whole reason this file exists.
|
||||
val ids = listOf("first", "second", "third").map(::eventId)
|
||||
val messages = ids.map { ByteVector(it.hexToByteArray()) }
|
||||
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
// A seed per signer per item. The manager mints these independently; here
|
||||
// they only have to differ, which is the property under test.
|
||||
val nonces = signerIds.map { signerId ->
|
||||
messages.mapIndexed { index, message ->
|
||||
nonceOf(signerId, message, "f".repeat(62) + "$index${signerId + 1}")
|
||||
}
|
||||
}
|
||||
|
||||
val signatures = messages.mapIndexed { index, message ->
|
||||
val session = sessionFor(signerIds, nonces.map { it[index].second }, message)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(
|
||||
nonces[position][index].first,
|
||||
keyMaterial.secretShares[signerId],
|
||||
signerId.toUInt()
|
||||
).right!!
|
||||
}
|
||||
|
||||
session.aggregateSigs(partials).right!!
|
||||
}
|
||||
|
||||
ids.forEachIndexed { index, id ->
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signatures[index].toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"item $index of the batch must verify against the room's own key"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch signature does not carry to another item of the same batch`() {
|
||||
// What keeps a batch k independent signatures rather than one loose one.
|
||||
// Signing three events in lockstep must not make any of them
|
||||
// interchangeable.
|
||||
val ids = listOf("first", "second").map(::eventId)
|
||||
val messages = ids.map { ByteVector(it.hexToByteArray()) }
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
messages.mapIndexed { index, message ->
|
||||
nonceOf(signerId, message, "9".repeat(62) + "$index${signerId + 1}")
|
||||
}
|
||||
}
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it[0].second }, messages[0])
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(
|
||||
nonces[position][0].first,
|
||||
keyMaterial.secretShares[signerId],
|
||||
signerId.toUInt()
|
||||
).right!!
|
||||
}
|
||||
val first = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertFalse(
|
||||
Nip01Crypto.verify(
|
||||
signature = first.toByteArray(),
|
||||
hash = ids[1].hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"item 0's signature must not verify against item 1"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two items of a batch never share a nonce`() {
|
||||
// The one mistake in this whole design that loses the key, asserted at the
|
||||
// level where it would be made. `SecretNonce.generate` mixes the message
|
||||
// in, so two items of a batch cannot collide even given the same seed --
|
||||
// but the manager gives them distinct seeds as well, and both halves are
|
||||
// checked here because either alone is enough to be relied on by accident.
|
||||
val messages = listOf("first", "second").map { ByteVector(eventId(it).hexToByteArray()) }
|
||||
|
||||
val sameSeed = messages.map { nonceOf(0, it, "7".repeat(64)).second.data.toHex() }
|
||||
assertEquals(2, sameSeed.toSet().size, "one seed under two messages must give two nonces")
|
||||
|
||||
val distinctSeeds = messages.mapIndexed { index, message ->
|
||||
nonceOf(0, message, "8".repeat(63) + "$index").second.data.toHex()
|
||||
}
|
||||
assertEquals(2, distinctSeeds.toSet().size)
|
||||
assertEquals(emptySet(), sameSeed.toSet().intersect(distinctSeeds.toSet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature over one event does not verify against another`() {
|
||||
val id = eventId("the group agrees")
|
||||
@@ -227,20 +327,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 +359,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 +375,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 +530,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 +540,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 +575,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 +587,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 +610,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,
|
||||
|
||||
+5
-12
@@ -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)
|
||||
|
||||
+1
-4
@@ -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
|
||||
)
|
||||
}
|
||||
+12
-19
@@ -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
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package press.mantra.compose.nostr.frost
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* How a proposal's events go on the wire, and what an old build makes of them.
|
||||
*
|
||||
* The whole reason this encoding has two shapes rather than one is
|
||||
* compatibility, and compatibility is exactly the thing no other test in this
|
||||
* area can see: a build that predates batching is not here to be run against.
|
||||
* So the property is asserted from the other side -- a batch of one is byte for
|
||||
* byte the JSON object it always was, which is the only form such a build can
|
||||
* read.
|
||||
*/
|
||||
class FrostProposalCodecTest {
|
||||
private fun event(content: String, kind: Int = 1) = Event(
|
||||
id = "a".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = kind,
|
||||
tags = arrayOf(arrayOf("alt", "test")),
|
||||
content = content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a batch of one is the bare event, exactly as it always was`() {
|
||||
val one = event("just this")
|
||||
|
||||
// The load-bearing assertion. A device on a build that predates batching
|
||||
// parses this with Event.fromJsonOrNull; anything but the bare object
|
||||
// returns null there and the proposal is dropped, so every single-event
|
||||
// session in a mixed-version group would stop working.
|
||||
assertEquals(one.toJson(), FrostSigningEvents.encodeProposal(listOf(one)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real batch is an array`() {
|
||||
val encoded = FrostSigningEvents.encodeProposal(
|
||||
listOf(event("first"), event("second"))
|
||||
)
|
||||
|
||||
assertTrue(encoded.startsWith("["), "a batch must be a JSON array")
|
||||
assertTrue(encoded.endsWith("]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what is encoded is what comes back, in order`() {
|
||||
val events = listOf(event("first"), event("second"), event("third"))
|
||||
|
||||
val decoded = FrostSigningEvents.decodeProposal(
|
||||
FrostSigningEvents.encodeProposal(events)
|
||||
)
|
||||
|
||||
// Order is the batch's index, and every join of nonces and partial
|
||||
// signatures is positional against it.
|
||||
assertEquals(
|
||||
events.map { it.content },
|
||||
decoded?.map { it.content }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare event decodes as a batch of one, forever`() {
|
||||
// Proposals in the old shape do not stop arriving just because this build
|
||||
// stopped writing them.
|
||||
val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson())
|
||||
|
||||
assertEquals(1, decoded?.size)
|
||||
assertEquals("from an older build", decoded?.single()?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an array with one unreadable element is refused whole`() {
|
||||
// Not shortened. The batch's length is what every later payload is checked
|
||||
// against, so a proposal that quietly lost an event would have every
|
||||
// signer's contribution rejected for being the wrong size.
|
||||
val encoded = """[${event("fine").toJson()},{"not":"an event"}]"""
|
||||
|
||||
assertNull(FrostSigningEvents.decodeProposal(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty array is not a proposal`() {
|
||||
assertNull(FrostSigningEvents.decodeProposal("[]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nonsense is not a proposal`() {
|
||||
assertNull(FrostSigningEvents.decodeProposal("[this is not json"))
|
||||
assertNull(FrostSigningEvents.decodeProposal(""))
|
||||
assertNull(FrostSigningEvents.decodeProposal("null"))
|
||||
}
|
||||
}
|
||||
+111
-6
@@ -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))
|
||||
}
|
||||
}
|
||||
+186
@@ -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"))
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What a group gets for choosing "robust": the key ceremony its room opens with.
|
||||
*
|
||||
* The quorum picked while creating the group has nothing in NIP-17 to govern --
|
||||
* there is no group state to change, and a different member set is simply a
|
||||
* different room -- so the only thing that can carry it is a t-of-n key the
|
||||
* members generate together. Everything that ceremony needs is settled the
|
||||
* moment the room exists, so `SelectChatRoomTypeViewModel.createNip17ChatRoom`
|
||||
* opens one there and then rather than leaving it for somebody to find a button
|
||||
* for. These are the properties that has to hold to.
|
||||
*
|
||||
* The proposal being the room's *first* event is not a nicety. Standing up a
|
||||
* NIP-17 room sends nothing to anybody, so until something goes out the group
|
||||
* exists on one device only; the proposal is what the other members hear the
|
||||
* room from at all, which is why `NostrDao` builds the room from a DKG payload's
|
||||
* p-tags on the way in.
|
||||
*
|
||||
* The view model itself is not exercised here -- it needs a wallet's key manager
|
||||
* and a Compose runtime -- so what stays uncovered is the wiring: that the
|
||||
* quorum reaching [ChillDkgRitualManager.proposeRitual] is the one the picker
|
||||
* holds, and that a null session leaves the user on the creation screen.
|
||||
*/
|
||||
class RobustRoomKeyCeremonyTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
// Real keys throughout: the room id is derived by doing point work over the
|
||||
// member set, and the host key each member is identified by for the ceremony
|
||||
// is derived from their nostr secret. Hex filler exercises neither.
|
||||
private val creator = KeyPair()
|
||||
private val user = creator.pubKey.toHexKey()
|
||||
private val creatorPrivateKey = creator.privKey!!
|
||||
private val alice = KeyPair().pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
|
||||
/** The quorum a three-member group is offered by default, and picks here. */
|
||||
private val quorum = 2
|
||||
|
||||
private suspend fun seedProfile(publicKey: String) {
|
||||
val nostrEventId = publicKey.take(63) + "f"
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
/** The room the creation screen leaves behind for a robust group. */
|
||||
private suspend fun robustRoom(): LocalChatRoom {
|
||||
listOf(user, alice, bob).forEach { seedProfile(it) }
|
||||
|
||||
return assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice, bob),
|
||||
subject = "Robust Group",
|
||||
),
|
||||
"createNip17ChatRoom returned null",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) =
|
||||
ChillDkgRitualManager.proposeRitual(
|
||||
database = db,
|
||||
localChatRoom = room,
|
||||
userPublicKey = user,
|
||||
nostrPrivateKey = creatorPrivateKey,
|
||||
threshold = threshold,
|
||||
)
|
||||
|
||||
private suspend fun ritualPayloads(room: LocalChatRoom) =
|
||||
db.giftWrapPayloadDao().getByChatRoomAndKinds(room.chatRoom.id, DkgRitualEvents.ALL.toList())
|
||||
|
||||
/** The room's lines in the order they were written, id being the insertion order. */
|
||||
private suspend fun chatMessages(room: LocalChatRoom) =
|
||||
db.chatMessageDao().getChatMessagesByChatRoomId(room.chatRoom.id)
|
||||
.map { it.chatMessage }
|
||||
.sortedBy { it.id }
|
||||
|
||||
/**
|
||||
* The whole of the change: a robust room has nothing in it until the ceremony,
|
||||
* and the ceremony is the first thing in it.
|
||||
*/
|
||||
@Test
|
||||
fun `a robust room's first message is the key ceremony it opens with`() = runBlocking {
|
||||
val room = robustRoom()
|
||||
|
||||
assertTrue(chatMessages(room).isEmpty(), "the room says nothing until something is sent")
|
||||
assertTrue(ritualPayloads(room).isEmpty(), "and nothing has gone out yet")
|
||||
|
||||
val session = openCeremony(room)
|
||||
|
||||
assertEquals(
|
||||
ChatMessage.TYPE_DKG_STARTED,
|
||||
chatMessages(room).first().messageType,
|
||||
"the first line in a robust room is its ceremony opening",
|
||||
)
|
||||
|
||||
val proposal = ritualPayloads(room).first()
|
||||
assertEquals(DkgRitualEvents.PROPOSAL, proposal.kind, "and the first event out is the proposal")
|
||||
assertEquals(user, proposal.publicKey)
|
||||
|
||||
// What every receiver rebuilds the ceremony from: `n` is the p-tag set plus
|
||||
// the sender, and `t` is the quorum the group was created on. Both are read
|
||||
// off this event rather than off the receiver's own view of the room, which
|
||||
// is the thing that drifts.
|
||||
assertEquals(
|
||||
setOf(user, alice, bob),
|
||||
proposal.participantPTags().map { it.pubKey }.toSet(),
|
||||
"the proposal carries the whole membership",
|
||||
)
|
||||
assertEquals(quorum, DkgRitualEvents.parseThreshold(proposal.tags))
|
||||
assertEquals(session.id, DkgRitualEvents.parseSessionId(proposal.tags))
|
||||
|
||||
assertEquals(quorum, session.threshold, "the ceremony runs at the quorum that was picked")
|
||||
assertEquals(3, session.participantCount)
|
||||
assertEquals(user, session.coordinatorPublicKey, "the room's creator coordinates")
|
||||
|
||||
// Opening a ceremony is the act of agreeing to be in it, so this member is
|
||||
// not asked again -- and their host key goes out with the proposal.
|
||||
assertNotNull(session.hostKeyApprovedAt)
|
||||
assertEquals(
|
||||
1,
|
||||
ritualPayloads(room).count { it.kind == DkgRitualEvents.HOST_KEY },
|
||||
"the coordinator joins the ceremony it opened",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation is re-enterable -- the room id is derived from the member set, so the
|
||||
* same group made twice is the same room -- and the ceremony has to be
|
||||
* re-enterable with it. A second proposal is a second participant set for every
|
||||
* member to reconcile, and the key the first one produced would be left with
|
||||
* nothing pointing at it.
|
||||
*/
|
||||
@Test
|
||||
fun `making the same group twice does not open a second ceremony`() = runBlocking {
|
||||
val room = robustRoom()
|
||||
val first = openCeremony(room)
|
||||
|
||||
val again = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = listOf(alice, bob),
|
||||
subject = "Robust Group",
|
||||
)
|
||||
)
|
||||
assertEquals(room.chatRoom.id, again.chatRoom.id, "the same members are the same room")
|
||||
|
||||
// Asked for a different quorum this time: the running ceremony settled that
|
||||
// question when it opened, and answers with itself rather than the ask.
|
||||
val second = openCeremony(again, threshold = 3)
|
||||
|
||||
assertEquals(first.id, second.id)
|
||||
assertEquals(quorum, second.threshold)
|
||||
assertEquals(
|
||||
1,
|
||||
ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL },
|
||||
"one proposal, not two",
|
||||
)
|
||||
assertEquals(
|
||||
1,
|
||||
chatMessages(room).count { it.messageType == ChatMessage.TYPE_DKG_STARTED },
|
||||
"and the room is told once",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed ceremony leaves the group with no key and a room they can still
|
||||
* talk in, which is exactly the group that should be able to try again. Failed
|
||||
* is the one state that does not hold the room's ceremony slot.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed ceremony is replaced rather than handed back`() = runBlocking {
|
||||
val room = robustRoom()
|
||||
val abandoned = openCeremony(room)
|
||||
|
||||
db.dkgSessionDao().upsert(abandoned.copy(stage = DkgRitualStage.FAILED))
|
||||
|
||||
val replacement = openCeremony(room)
|
||||
|
||||
assertNotEquals(abandoned.id, replacement.id)
|
||||
assertEquals(DkgRitualStage.COLLECTING_HOST_KEYS, replacement.stage)
|
||||
assertEquals(
|
||||
2,
|
||||
ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL },
|
||||
"the group proposes again",
|
||||
)
|
||||
}
|
||||
}
|
||||
+329
-8
@@ -14,11 +14,13 @@ import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
@@ -121,6 +123,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 +284,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 +298,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 +329,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 +346,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 +408,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)
|
||||
@@ -446,6 +460,313 @@ class SignedGroupKeyStateTest {
|
||||
assertEquals(adminRoomId, absent.keyState()?.announcedBy)
|
||||
}
|
||||
|
||||
// ---- Signing several events in one session -----------------------------
|
||||
|
||||
/**
|
||||
* The batch, end to end, between two devices over two databases: three
|
||||
* events, one signer set, one approval, and three signatures that verify
|
||||
* against the room.
|
||||
*
|
||||
* Everything about batching that could be wrong and still compile is wrong
|
||||
* here or nowhere -- a shared nonce, a mismatched order, a payload split the
|
||||
* wrong way. A signature that verifies is the only evidence any of it is
|
||||
* wired up correctly, and there are three of them to disagree.
|
||||
*/
|
||||
@Test
|
||||
fun `a batch of three is signed in one session`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = dialects()
|
||||
)
|
||||
pump(creator, other)
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
listOf(creator, other).forEach { device ->
|
||||
val items = device.items(session.id)
|
||||
|
||||
assertEquals(3, items.size, "every device signs the whole batch")
|
||||
assertEquals(FrostSigningStage.COMPLETE, device.session(session.id)?.stage)
|
||||
|
||||
items.forEach { item ->
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = assertNotNull(item.signature).hexToByteArray(),
|
||||
hash = item.eventId.hexToByteArray(),
|
||||
pubKey = adminRoomId.hexToByteArray()
|
||||
),
|
||||
"item ${item.itemIndex} must verify against the room it was signed in"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one that catches the mistake this whole design exists to prevent.
|
||||
*
|
||||
* Every positive test above still passes if two items share a nonce -- the
|
||||
* signatures verify perfectly well. What sharing costs is the secret share,
|
||||
* to anyone who sees both partial signatures. So the aggregates and the seeds
|
||||
* are asserted pairwise distinct, which is cheap and is the only assertion
|
||||
* here that an index bug cannot slip past.
|
||||
*/
|
||||
@Test
|
||||
fun `no two events in a batch share a nonce`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = dialects()
|
||||
)
|
||||
pump(creator, other)
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
listOf(creator, other).forEach { device ->
|
||||
val items = device.items(session.id)
|
||||
|
||||
assertEquals(3, items.mapNotNull { it.aggregatedNonce }.toSet().size)
|
||||
assertEquals(3, items.map { it.nonceRandom }.toSet().size)
|
||||
assertEquals(3, items.map { it.eventId }.toSet().size)
|
||||
}
|
||||
|
||||
// A seed is this device's own, so the two devices must not have arrived
|
||||
// at the same ones either.
|
||||
assertEquals(
|
||||
emptySet(),
|
||||
creator.items(session.id).map { it.nonceRandom }.toSet()
|
||||
.intersect(other.items(session.id).map { it.nonceRandom }.toSet())
|
||||
)
|
||||
}
|
||||
|
||||
/** Four messages for three events, which is the whole point of batching. */
|
||||
@Test
|
||||
fun `a batch costs one round of messages, not one per event`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = dialects()
|
||||
)
|
||||
pump(creator, other)
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
// Proposal, nonce, signer set, partial signature, signature.
|
||||
assertEquals(
|
||||
listOf(
|
||||
FrostSigningEvents.PROPOSAL,
|
||||
FrostSigningEvents.NONCE,
|
||||
FrostSigningEvents.SIGNER_SET,
|
||||
FrostSigningEvents.PARTIAL_SIGNATURE,
|
||||
FrostSigningEvents.SIGNATURE,
|
||||
),
|
||||
outbox(creator).map { it.kind }
|
||||
)
|
||||
|
||||
// The other member is a signer and not the coordinator: one nonce and one
|
||||
// partial signature, each carrying all three values.
|
||||
assertEquals(
|
||||
listOf(FrostSigningEvents.NONCE, FrostSigningEvents.PARTIAL_SIGNATURE),
|
||||
outbox(other).map { it.kind }
|
||||
)
|
||||
outbox(other).forEach { queued ->
|
||||
assertEquals(3, queued.content.split(",").size, "kind ${queued.kind} must carry three values")
|
||||
}
|
||||
|
||||
// And one question put to the member, not three.
|
||||
assertEquals(
|
||||
1,
|
||||
other.db.chatMessageDao().getChatMessagesByChatRoomId(other.roomId)
|
||||
.count { it.chatMessage.messageType == ChatMessage.TYPE_FROST_APPROVAL_NEEDED }
|
||||
)
|
||||
}
|
||||
|
||||
/** Every event of a batch is applied, not just the first. */
|
||||
@Test
|
||||
fun `every dialect in a batch lands on every device`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = dialects()
|
||||
)
|
||||
pump(creator, other)
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
listOf(creator, other).forEach { device ->
|
||||
val names = device.items(session.id).map { item ->
|
||||
assertNotNull(
|
||||
device.db.mantraDialectDao().getDialectById(item.eventId),
|
||||
"item ${item.itemIndex} should have been applied"
|
||||
).name
|
||||
}
|
||||
|
||||
assertEquals(listOf("Sepedi", "isiZulu", "Setswana"), names)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A proposal is the one place a remote party decides how much work everybody
|
||||
* else does, so the size cap is checked on arrival and not only when
|
||||
* proposing.
|
||||
*/
|
||||
@Test
|
||||
fun `a batch larger than the cap is refused when proposed`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
|
||||
val tooMany = (0..FrostSigningManager.MAX_BATCH_SIZE).map { index ->
|
||||
DialectEvent.build(name = "d$index", country = "ZA", language = "l$index")
|
||||
}
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = tooMany
|
||||
)
|
||||
}
|
||||
|
||||
Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload that is not the batch's length is left out, not truncated -- and
|
||||
* leaving it out stalls the session rather than poisoning it.
|
||||
*
|
||||
* The length check is the only thing standing between a batch and a signer
|
||||
* whose contribution lines up against the wrong messages. Truncating a long
|
||||
* payload or padding a short one would produce partial signatures aggregated
|
||||
* against events nobody agreed to.
|
||||
*/
|
||||
@Test
|
||||
fun `a nonce payload of the wrong length is left out rather than truncated`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = dialects()
|
||||
)
|
||||
pump(creator, other)
|
||||
|
||||
// One nonce offered for a batch of three, from a member the coordinator
|
||||
// would otherwise have picked.
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
innerEvent = Event(
|
||||
id = "1".repeat(64),
|
||||
pubKey = other.publicKey,
|
||||
createdAt = 1,
|
||||
kind = FrostSigningEvents.NONCE,
|
||||
tags = FrostSigningEvents.assembleTags(session.id),
|
||||
content = "aa".repeat(66),
|
||||
sig = ""
|
||||
),
|
||||
userPublicKey = creator.publicKey
|
||||
)
|
||||
|
||||
assertNull(
|
||||
creator.session(session.id)?.signerIds,
|
||||
"a payload of the wrong length must not count towards the threshold"
|
||||
)
|
||||
|
||||
// And it is a stall, not damage: the real nonce replaces it and the batch
|
||||
// finishes. That is the composite key on FrostSignerMessage doing its job.
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
assertEquals(FrostSigningStage.COMPLETE, creator.session(session.id)?.stage)
|
||||
assertEquals(3, creator.items(session.id).count { it.signature != null })
|
||||
}
|
||||
|
||||
/**
|
||||
* A second proposal under a session's own id cannot change what it signs.
|
||||
*
|
||||
* The rule the whole design rests on. Every item's nonce seed is already
|
||||
* committed to that item's message; giving the session a different batch
|
||||
* would have those seeds produce a second partial signature over a second
|
||||
* message, which is how a secret share is extracted.
|
||||
*/
|
||||
@Test
|
||||
fun `a second proposal under the same id cannot change what a session signs`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = FrostSigningManager.proposeSigningBatch(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
events = dialects()
|
||||
)
|
||||
pump(creator, other)
|
||||
|
||||
val before = other.items(session.id)
|
||||
|
||||
val substitute = DialectEvent.build(name = "Xitsonga", country = "ZA", language = "tso")
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = other.db,
|
||||
localChatRoom = other.room,
|
||||
innerEvent = Event(
|
||||
id = "2".repeat(64),
|
||||
pubKey = creator.publicKey,
|
||||
createdAt = 1,
|
||||
kind = FrostSigningEvents.PROPOSAL,
|
||||
tags = FrostSigningEvents.assembleTags(session.id, dkgSessionId = ceremonyId),
|
||||
content = FrostSigningEvents.encodeProposal(
|
||||
listOf(
|
||||
Event(
|
||||
id = "3".repeat(64),
|
||||
pubKey = adminRoomId,
|
||||
createdAt = substitute.createdAt,
|
||||
kind = substitute.kind,
|
||||
tags = substitute.tags,
|
||||
content = substitute.content,
|
||||
sig = ""
|
||||
)
|
||||
)
|
||||
),
|
||||
sig = ""
|
||||
),
|
||||
userPublicKey = other.publicKey
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
before.map { it.eventId },
|
||||
other.items(session.id).map { it.eventId },
|
||||
"a re-proposal must be ignored, not applied"
|
||||
)
|
||||
assertEquals(before.map { it.nonceRandom }, other.items(session.id).map { it.nonceRandom })
|
||||
}
|
||||
|
||||
/** Three dialects, distinct enough that an order bug shows up as a wrong name. */
|
||||
private fun dialects() = listOf(
|
||||
Triple("Sepedi", "ZA", "nso"),
|
||||
Triple("isiZulu", "ZA", "zul"),
|
||||
Triple("Setswana", "ZA", "tsn"),
|
||||
).map { (name, country, language) ->
|
||||
DialectEvent.build(name = name, country = country, language = language)
|
||||
}
|
||||
|
||||
// ---- What a room signs as, once it has a key state ---------------------
|
||||
|
||||
@Test
|
||||
@@ -467,7 +788,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 +922,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 +961,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user