Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64
This commit is contained in:
@@ -106,6 +106,7 @@ import press.mantra.compose.database.model.RecentSearch
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.Relay
|
||||
import press.mantra.compose.database.model.RepostedRelation
|
||||
@@ -130,6 +131,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
DkgParticipantMessage::class,
|
||||
DkgSession::class,
|
||||
FrostSignerMessage::class,
|
||||
FrostSigningItem::class,
|
||||
FrostSigningSession::class,
|
||||
GiftWrapMessage::class,
|
||||
GiftWrapSeal::class,
|
||||
@@ -172,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 9,
|
||||
version = 10,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -215,6 +217,11 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// is what they were signing as, so one caught mid-flight finishes the way
|
||||
// it began rather than switching keys between two of its own rounds.
|
||||
AutoMigration(from = 8, to = 9)
|
||||
// v10 moves FrostSigningSession's five per-event columns onto the new
|
||||
// FrostSigningItem table, so one session can sign a batch. It copies
|
||||
// before it drops, which no AutoMigration can express -- see
|
||||
// MIGRATION_9_10, and the note there on why an in-flight session losing
|
||||
// its nonce seed would be worse than losing the session.
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
@@ -3,6 +3,7 @@ package press.mantra.compose.database.builder
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import press.mantra.compose.database.migrations.MIGRATION_3_4
|
||||
import press.mantra.compose.database.migrations.MIGRATION_9_10
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
|
||||
@@ -16,9 +17,11 @@ fun getRoomDatabase(
|
||||
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
|
||||
): press.mantra.compose.database.MantraDatabase {
|
||||
return builder
|
||||
// Everything else Room generates itself; this one rewrites rows rather than
|
||||
// changing shape, which an AutoMigration cannot express.
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
// Everything else Room generates itself. These two move data rather than
|
||||
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
|
||||
// chat rows, and 9->10 copies a session's per-event columns onto the items
|
||||
// table before dropping them.
|
||||
.addMigrations(MIGRATION_3_4, MIGRATION_9_10)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.build()
|
||||
|
||||
@@ -2,11 +2,13 @@ package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
|
||||
@Dao
|
||||
@@ -36,6 +38,46 @@ interface FrostSigningSessionDao {
|
||||
@Upsert
|
||||
suspend fun upsert(frostSignerMessage: FrostSignerMessage)
|
||||
|
||||
/**
|
||||
* A session's events, in the order the batch fixed at proposal.
|
||||
*
|
||||
* Every join of nonces and partial signatures is positional, so this
|
||||
* ordering is part of the protocol rather than a presentation choice —
|
||||
* two devices reading a batch in different orders aggregate against
|
||||
* different messages.
|
||||
*/
|
||||
@Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC")
|
||||
suspend fun getItems(sessionId: String): List<FrostSigningItem>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC")
|
||||
fun observeItems(sessionId: String): Flow<List<FrostSigningItem>>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId AND itemIndex = :itemIndex")
|
||||
suspend fun getItem(sessionId: String, itemIndex: Int): FrostSigningItem?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(frostSigningItem: FrostSigningItem)
|
||||
|
||||
/**
|
||||
* Writes a whole batch's worth of items at once.
|
||||
*
|
||||
* A transaction rather than a loop because the coordinator's two broadcasts
|
||||
* each settle every item together: a half-written aggregate would leave the
|
||||
* session gated on `signerIds` while some items had no nonce to aggregate
|
||||
* against, which is a stall no message can clear.
|
||||
*/
|
||||
@Transaction
|
||||
@Upsert
|
||||
suspend fun upsertItems(items: List<FrostSigningItem>)
|
||||
|
||||
/** How many events this session signs. Derived rather than stored — see [FrostSigningItem]. */
|
||||
@Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId")
|
||||
suspend fun countItems(sessionId: String): Int
|
||||
|
||||
/** How many of them the group has finished. Equal to [countItems] once it is done. */
|
||||
@Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId AND signature IS NOT NULL")
|
||||
suspend fun countSignedItems(sessionId: String): Int
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC")
|
||||
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<FrostSignerMessage>
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
/**
|
||||
* Moves a signing session's per-event columns onto `FrostSigningItem`, so a
|
||||
* session can sign more than one event.
|
||||
*
|
||||
* Not an `AutoMigration`. Room can create the table and it can drop the columns,
|
||||
* but it cannot copy between them, and this migration is entirely about the copy.
|
||||
*
|
||||
* ### Why the backfill has to be exact
|
||||
*
|
||||
* A session in flight at upgrade time holds two things that cannot be
|
||||
* regenerated: `nonceRandom`, the seed its secret nonce is derived from, and
|
||||
* `aggregatedNonce`, the aggregate its partial signature is made against. Lose
|
||||
* the seed and the next pass derives a *different* nonce for the same message,
|
||||
* then publishes a second partial signature over it -- two partial signatures,
|
||||
* one message, two nonces, which is precisely how a secret share is extracted.
|
||||
* Lose the aggregate and the session republishes against a new one, with the
|
||||
* same result.
|
||||
*
|
||||
* So this copies them verbatim into item 0, and an in-flight session resumes as
|
||||
* though nothing happened. Dropping the columns and letting sessions start over
|
||||
* would have been the one dangerous way to write this migration.
|
||||
*
|
||||
* `signerIds` stays on the session: it is shared by every item of a batch, and
|
||||
* an upgraded session keeps whichever set it had chosen.
|
||||
*
|
||||
* ### Why `DROP COLUMN` rather than a table rebuild
|
||||
*
|
||||
* The usual way to remove columns in SQLite -- create a new table, copy, drop
|
||||
* the old one, rename -- is unsafe here and quietly so. `FrostSignerMessage` and
|
||||
* the new `FrostSigningItem` both reference `FrostSigningSession(id)`
|
||||
* `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: with foreign keys
|
||||
* enforced it would delete every signer message and every item this migration
|
||||
* had just written. It would depend entirely on Room having turned foreign keys
|
||||
* off around the migration, which is not a thing worth depending on when there
|
||||
* is an alternative that cannot go wrong.
|
||||
*
|
||||
* `ALTER TABLE ... DROP COLUMN` is that alternative. It needs SQLite 3.35, and
|
||||
* the columns must be free of indices and constraints -- these five are, and
|
||||
* `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version
|
||||
* is ours rather than the host's. The table, its foreign key and both its
|
||||
* indices are left alone.
|
||||
*/
|
||||
val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `FrostSigningItem` (" +
|
||||
"`sessionId` TEXT NOT NULL, " +
|
||||
"`itemIndex` INTEGER NOT NULL, " +
|
||||
"`unsignedEventJson` TEXT NOT NULL, " +
|
||||
"`eventId` TEXT NOT NULL, " +
|
||||
"`nonceRandom` TEXT NOT NULL, " +
|
||||
"`aggregatedNonce` TEXT, " +
|
||||
"`signature` TEXT, " +
|
||||
"PRIMARY KEY(`sessionId`, `itemIndex`), " +
|
||||
"FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` " +
|
||||
"ON `FrostSigningItem` (`sessionId`)"
|
||||
)
|
||||
|
||||
// Every existing session signed exactly one event, so every one of them
|
||||
// becomes a batch of one at index 0.
|
||||
connection.execSQL(
|
||||
"INSERT INTO `FrostSigningItem` " +
|
||||
"(`sessionId`, `itemIndex`, `unsignedEventJson`, `eventId`, " +
|
||||
"`nonceRandom`, `aggregatedNonce`, `signature`) " +
|
||||
"SELECT `id`, 0, `unsignedEventJson`, `eventId`, " +
|
||||
"`nonceRandom`, `aggregatedNonce`, `signature` " +
|
||||
"FROM `FrostSigningSession`"
|
||||
)
|
||||
|
||||
listOf(
|
||||
"unsignedEventJson",
|
||||
"eventId",
|
||||
"nonceRandom",
|
||||
"aggregatedNonce",
|
||||
"signature",
|
||||
).forEach { column ->
|
||||
connection.execSQL("ALTER TABLE `FrostSigningSession` DROP COLUMN `$column`")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* One event a signing session signs, and everything about it that cannot be
|
||||
* shared with the others.
|
||||
*
|
||||
* A session signs a *batch*: one ceremony, one signer set, one approval, and `n`
|
||||
* events. What separates them is forced by FROST rather than chosen. A Schnorr
|
||||
* partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
|
||||
* one nonce `R` give two equations in one unknown and the secret share falls
|
||||
* out. Every item therefore carries its own nonce material and its own result,
|
||||
* and the only things a batch may hold in common are the ones that do not enter
|
||||
* that equation -- see [FrostSigningSession].
|
||||
*
|
||||
* These five columns lived on [FrostSigningSession] until the schema moved to
|
||||
* v10, which is why a session created before it reads back as exactly one item
|
||||
* at [itemIndex] 0.
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["sessionId", "itemIndex"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = FrostSigningSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("sessionId"),
|
||||
],
|
||||
)
|
||||
data class FrostSigningItem(
|
||||
val sessionId: String,
|
||||
|
||||
/**
|
||||
* Position in the batch, from zero.
|
||||
*
|
||||
* Load-bearing rather than cosmetic: it is the order every device joins
|
||||
* nonces and partial signatures in, so two devices that disagree about it
|
||||
* aggregate against different messages and produce a signature nobody can
|
||||
* verify. Fixed by the proposal and never re-sorted.
|
||||
*
|
||||
* Named `itemIndex` rather than `index` because `index` needs quoting in
|
||||
* every hand-written query it appears in, and one missing backtick is a
|
||||
* compile error at best.
|
||||
*/
|
||||
val itemIndex: Int,
|
||||
|
||||
/**
|
||||
* The unsigned event, as JSON. Kept whole so a member can be shown what
|
||||
* they are being asked to sign rather than a hash of it.
|
||||
*/
|
||||
val unsignedEventJson: String,
|
||||
|
||||
/**
|
||||
* The event id, which is the 32 bytes actually signed.
|
||||
*
|
||||
* Recomputed from the event's own fields on arrival, never taken from the
|
||||
* proposal. It pins this item to one message -- which is what makes reusing
|
||||
* [nonceRandom] safe, exactly as it was when a session signed one event.
|
||||
*/
|
||||
val eventId: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness, the seed for this item's nonce. */
|
||||
val nonceRandom: HexKey,
|
||||
|
||||
/**
|
||||
* The coordinator's `AggregatedNonce` for this item once it arrives, hex.
|
||||
*
|
||||
* Written once, and written for every item of the batch in one transaction
|
||||
* alongside [FrostSigningSession.signerIds] -- so "some items aggregated" is
|
||||
* a state that cannot be reached, and `signerIds != null` is the one gate
|
||||
* the rest of the session reads.
|
||||
*/
|
||||
val aggregatedNonce: HexKey? = null,
|
||||
|
||||
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
|
||||
val signature: HexKey? = null,
|
||||
) {
|
||||
/** Whether the group has produced this item's signature. */
|
||||
fun isSigned(): Boolean = signature != null
|
||||
}
|
||||
@@ -21,22 +21,40 @@ import press.mantra.compose.managers.SharedKeyDerivation
|
||||
* convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce`
|
||||
* cannot be serialised and refuses to be used twice, by design.
|
||||
*
|
||||
* ### The nonce, and why one session means one message
|
||||
* ### One session, several events
|
||||
*
|
||||
* [nonceRandom] is secret, and regenerating this device's nonce from it is safe
|
||||
* for exactly one reason: a session signs one message and can never be made to
|
||||
* sign another. `SecretNonce.generate` mixes the message in, so the same
|
||||
* randomness under a different message would be a different nonce -- but the
|
||||
* same randomness under the same message with two *different* aggregated nonces
|
||||
* would produce two partial signatures over one secret nonce, which is how a
|
||||
* secret share is extracted.
|
||||
* A session signs a batch of one or more events, held as [FrostSigningItem]
|
||||
* rows. What lives here is what the whole batch shares, and the split is not a
|
||||
* matter of taste: an item's nonce, message and signature are the terms of
|
||||
* `s = k + e·x` with `e = H(R‖P‖m)`, so sharing any of them across two messages
|
||||
* is how a secret share is extracted. Everything on this row is outside that
|
||||
* equation.
|
||||
*
|
||||
* | shared, and so here | per item, and so on [FrostSigningItem] |
|
||||
* |---|---|
|
||||
* | ceremony, threshold, participant count | the unsigned event and its id |
|
||||
* | [signerIds] and the signer set | nonce seed, and the nonce from it |
|
||||
* | [derivationPath] and its tweak cache | aggregated nonce |
|
||||
* | [signApprovedAt] -- one decision | the signature |
|
||||
*
|
||||
* ### The nonce, and why one item means one message
|
||||
*
|
||||
* [FrostSigningItem.nonceRandom] is secret, and regenerating this device's nonce
|
||||
* from it is safe for exactly one reason: an item signs one message and can
|
||||
* never be made to sign another. `SecretNonce.generate` mixes the message in, so
|
||||
* the same randomness under a different message would be a different nonce --
|
||||
* but the same randomness under the same message with two *different* aggregated
|
||||
* nonces would produce two partial signatures over one secret nonce, which is
|
||||
* how a secret share is extracted.
|
||||
*
|
||||
* Two rules keep that impossible, and both are load-bearing:
|
||||
*
|
||||
* - [eventId] is written when the session is created and a proposal that
|
||||
* disagrees with it is rejected rather than applied.
|
||||
* - [aggregatedNonce] and [signerIds] are written once. A second, different
|
||||
* signer set for the same session is ignored, not honoured.
|
||||
* - [FrostSigningItem.eventId] is written when the session is created, and a
|
||||
* proposal that disagrees with the batch it already holds is rejected rather
|
||||
* than applied.
|
||||
* - [FrostSigningItem.aggregatedNonce] and [signerIds] are written once, and
|
||||
* written together. A second, different signer set for the same session is
|
||||
* ignored, not honoured.
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
@@ -92,38 +110,16 @@ data class FrostSigningSession(
|
||||
* it is running in. Null is the honest answer for a room that is not derived
|
||||
* from the key at all, and is what sessions predating this column read back
|
||||
* as -- both of which signed as the threshold key itself.
|
||||
*
|
||||
* Shared by the whole batch: every item of a session signs as the same room.
|
||||
*/
|
||||
val derivationPath: String? = null,
|
||||
|
||||
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
|
||||
/**
|
||||
* The unsigned event, as JSON. Kept whole so a member can be shown what
|
||||
* they are being asked to sign rather than a hash of it.
|
||||
*/
|
||||
val unsignedEventJson: String,
|
||||
|
||||
/**
|
||||
* The event id, which is the 32 bytes actually signed.
|
||||
*
|
||||
* Recomputed from the event's own fields on arrival, never taken from the
|
||||
* proposal. It pins the session to one message -- see the class note on why
|
||||
* that is what makes reusing [nonceRandom] safe.
|
||||
*/
|
||||
val eventId: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */
|
||||
val nonceRandom: HexKey,
|
||||
|
||||
/** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */
|
||||
val aggregatedNonce: HexKey? = null,
|
||||
|
||||
/** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */
|
||||
val signerIds: String? = null,
|
||||
|
||||
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
|
||||
val signature: HexKey? = null,
|
||||
|
||||
val failureReason: String? = null,
|
||||
|
||||
/**
|
||||
@@ -131,10 +127,12 @@ data class FrostSigningSession(
|
||||
* session does on their behalf. Null until they do, and nothing of theirs
|
||||
* goes out before it is set.
|
||||
*
|
||||
* One gate rather than the DKG's three. What a signer is consenting to is
|
||||
* the event, and the event is fixed before they are asked: the second round
|
||||
* puts no new question to them, so asking again would be asking the same
|
||||
* question twice about a decision already made.
|
||||
* One gate rather than the DKG's three, and one gate for the whole batch
|
||||
* rather than one per item. What a signer is consenting to is the events,
|
||||
* and the events are fixed before they are asked: the second round puts no
|
||||
* new question to them, so asking again would be asking the same question
|
||||
* twice about a decision already made. That holds for a batch only as long
|
||||
* as the member can actually see every event in it before answering.
|
||||
*/
|
||||
val signApprovedAt: Instant? = null,
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,6 +543,7 @@ fun MantraNavHost(
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateToRoute = { chatRoomResultRoute ->
|
||||
navController.navigate(
|
||||
chatRoomResultRoute
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user