Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64

This commit is contained in:
Kgothatso Ngako
2026-09-06 10:07:40 +02:00
30 changed files with 8209 additions and 320 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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>

View File

@@ -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`")
}
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -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 }
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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 = {}
)
}

View File

@@ -543,6 +543,7 @@ fun MantraNavHost(
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onNavigateToRoute = { chatRoomResultRoute ->
navController.navigate(
chatRoomResultRoute

View File

@@ -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

View File

@@ -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
)
}
}

View File

@@ -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(),