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

This commit is contained in:
Kgothatso Ngako committed 2026-09-06 10:07:40 +02:00
commit 60abf243ed
30 files changed
+8209 -320

No files matched your search

@@ -106,6 +106,7 @@ import press.mantra.compose.database.model.RecentSearch
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.Relay
import press.mantra.compose.database.model.RepostedRelation
@@ -130,6 +131,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
DkgParticipantMessage::class,
DkgSession::class,
FrostSignerMessage::class,
FrostSigningItem::class,
FrostSigningSession::class,
GiftWrapMessage::class,
GiftWrapSeal::class,
@@ -172,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 9,
version = 10,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -215,6 +217,11 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// is what they were signing as, so one caught mid-flight finishes the way
// it began rather than switching keys between two of its own rounds.
AutoMigration(from = 8, to = 9)
// v10 moves FrostSigningSession's five per-event columns onto the new
// FrostSigningItem table, so one session can sign a batch. It copies
// before it drops, which no AutoMigration can express -- see
// MIGRATION_9_10, and the note there on why an in-flight session losing
// its nonce seed would be worse than losing the session.
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -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(),
@@ -20,6 +20,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.extensions.toHex
@@ -159,6 +160,105 @@ class FrostSigningRoundTest {
)
}
@Test
fun `one signer set signs a batch of three, and every signature verifies`() {
// The manager's batch, with the database taken out of the way: one signer
// set and one tweak cache shared, and a nonce, a Session and a signature
// per event. If any of that is wired up wrongly the aggregate simply
// fails to verify, which is the whole reason this file exists.
val ids = listOf("first", "second", "third").map(::eventId)
val messages = ids.map { ByteVector(it.hexToByteArray()) }
val signerIds = listOf(0, 1)
// A seed per signer per item. The manager mints these independently; here
// they only have to differ, which is the property under test.
val nonces = signerIds.map { signerId ->
messages.mapIndexed { index, message ->
nonceOf(signerId, message, "f".repeat(62) + "$index${signerId + 1}")
}
}
val signatures = messages.mapIndexed { index, message ->
val session = sessionFor(signerIds, nonces.map { it[index].second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(
nonces[position][index].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
session.aggregateSigs(partials).right!!
}
ids.forEachIndexed { index, id ->
assertTrue(
Nip01Crypto.verify(
signature = signatures[index].toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"item $index of the batch must verify against the room's own key"
)
}
}
@Test
fun `a batch signature does not carry to another item of the same batch`() {
// What keeps a batch k independent signatures rather than one loose one.
// Signing three events in lockstep must not make any of them
// interchangeable.
val ids = listOf("first", "second").map(::eventId)
val messages = ids.map { ByteVector(it.hexToByteArray()) }
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
messages.mapIndexed { index, message ->
nonceOf(signerId, message, "9".repeat(62) + "$index${signerId + 1}")
}
}
val session = sessionFor(signerIds, nonces.map { it[0].second }, messages[0])
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(
nonces[position][0].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
val first = session.aggregateSigs(partials).right!!
assertFalse(
Nip01Crypto.verify(
signature = first.toByteArray(),
hash = ids[1].hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"item 0's signature must not verify against item 1"
)
}
@Test
fun `two items of a batch never share a nonce`() {
// The one mistake in this whole design that loses the key, asserted at the
// level where it would be made. `SecretNonce.generate` mixes the message
// in, so two items of a batch cannot collide even given the same seed --
// but the manager gives them distinct seeds as well, and both halves are
// checked here because either alone is enough to be relied on by accident.
val messages = listOf("first", "second").map { ByteVector(eventId(it).hexToByteArray()) }
val sameSeed = messages.map { nonceOf(0, it, "7".repeat(64)).second.data.toHex() }
assertEquals(2, sameSeed.toSet().size, "one seed under two messages must give two nonces")
val distinctSeeds = messages.mapIndexed { index, message ->
nonceOf(0, message, "8".repeat(63) + "$index").second.data.toHex()
}
assertEquals(2, distinctSeeds.toSet().size)
assertEquals(emptySet(), sameSeed.toSet().intersect(distinctSeeds.toSet()))
}
@Test
fun `a signature over one event does not verify against another`() {
val id = eventId("the group agrees")
@@ -227,20 +327,30 @@ class FrostSigningSessionTest {
threshold = 2,
participantCount = 3,
signerId = signerId,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signerIds = signerIds
)
/** The one event such a session signs, signed or not. */
private fun items(signature: String? = null) = listOf(
FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signature = signature
)
)
@Test
fun `a session waits on its owner until they answer`() {
val open = session(signerId = 2, signerIds = null)
assertTrue(FrostSigningManager.isAwaitingApproval(open))
assertTrue(FrostSigningManager.isAwaitingApproval(open, items()))
assertFalse(
FrostSigningManager.isAwaitingApproval(
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
open.copy(signApprovedAt = Instant.fromEpochSeconds(1)),
items()
)
)
}
@@ -249,8 +359,12 @@ class FrostSigningSessionTest {
fun `a session that has settled asks its owner nothing`() {
val open = session(signerId = 2, signerIds = null)
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
assertFalse(
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items())
)
assertFalse(
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items())
)
}
@Test
@@ -261,9 +375,10 @@ class FrostSigningSessionTest {
// decision in that window offers two bad answers: a nonce nobody is
// waiting for, or a refusal that abandons a signature that exists.
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
.copy(signature = "a".repeat(128))
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
assertFalse(
FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128)))
)
}
@Test
@@ -415,7 +530,7 @@ class FrostSigningCompletionTest {
* never named them. Every column the completion path reads is here; the ones
* it must not need are deliberately left null.
*/
private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession(
private fun leftOutMemberSession() = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
@@ -425,24 +540,30 @@ class FrostSigningCompletionTest {
participantCount = 3,
signerId = 2,
derivationPath = SharedKeyDerivation.formatPath(),
signerIds = null,
signApprovedAt = null
)
/** The event that session signs: everything completing it needs, and nothing more. */
private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64),
aggregatedNonce = null,
signerIds = null,
signature = signature,
signApprovedAt = null
signature = signature
)
@Test
fun `a member who never took part can still check what the group signed`() {
val session = leftOutMemberSession(signature)
val signed = FrostSigningManager.signedEvent(session)!!
val item = leftOutMemberItem(signature)
val signed = FrostSigningManager.signedEvent(item)!!
assertTrue(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = session.eventId.hexToByteArray(),
hash = item.eventId.hexToByteArray(),
pubKey = signed.pubKey.hexToByteArray()
),
"completing must need only the row: the event, its id and the signature"
@@ -454,7 +575,7 @@ class FrostSigningCompletionTest {
// Not rebuilt and not rehashed: the id a session is pinned to is the id
// the signature is over, so anything that changed here would produce an
// event whose signature verifies against nothing.
val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!!
val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!!
assertEquals(unsignedEvent.id, signed.id)
assertEquals(unsignedEvent.pubKey, signed.pubKey)
@@ -466,15 +587,22 @@ class FrostSigningCompletionTest {
@Test
fun `there is no finished event until the signature arrives`() {
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem()))
}
@Test
fun `the arrived signature is what stops the session asking`() {
// The pair that matters to the screen and the transcript: the same row,
// before and after the group finished without this member.
assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession()))
assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature)))
assertTrue(
FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem()))
)
assertFalse(
FrostSigningManager.isAwaitingApproval(
leftOutMemberSession(),
listOf(leftOutMemberItem(signature))
)
)
}
@Test
@@ -482,7 +610,7 @@ class FrostSigningCompletionTest {
// What the check is for. A coordinator passing off something else must not
// get it applied and announced as the group's, and the row is all there is
// to catch it with.
val other = leftOutMemberSession(signature).copy(
val other = leftOutMemberItem(signature).copy(
eventId = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
@@ -19,7 +19,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
@@ -281,21 +281,14 @@ class GroupKeyStateTest {
val unsigned = unsignedKeyState(content, tags, createdAt, author)
return FrostSigningManager.signedEvent(
session = sessionOver(unsigned),
item = itemOver(unsigned),
signature = groupSignature(signer, unsigned.id)
)
}
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = "c00rd1na70r",
userPublicKey = "c00rd1na70r",
dkgSessionId = "ceremony-1",
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(path),
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
@@ -170,9 +170,6 @@ class SharedKeyDerivationTest {
threshold = 2,
participantCount = 3,
signerId = 0,
derivationPath = derivationPath,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64)
derivationPath = derivationPath
)
}
@@ -18,7 +18,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.extensions.toHex
@@ -100,24 +100,17 @@ class SignedArtifactTest {
sig = ""
)
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = proposer,
userPublicKey = proposer,
dkgSessionId = "k".repeat(64),
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(),
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
)
/** A quorum signing the session's event, in the manager's order. */
private fun groupSignature(session: FrostSigningSession): String {
val message = ByteVector(session.eventId.hexToByteArray())
/** A quorum signing the item's event, in the manager's order. */
private fun groupSignature(item: FrostSigningItem): String {
val message = ByteVector(item.eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
@@ -154,8 +147,8 @@ class SignedArtifactTest {
/** Everything from the form to the row a device holds afterwards. */
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel)))
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
return ArtifactEvent(
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
@@ -193,15 +186,15 @@ class SignedArtifactTest {
// to be the one that was signed rather than anything recomputed from the
// proposer. Otherwise members converge on nothing and each holds its own
// copy of what is meant to be one artifact.
val session = sessionOver(unsignedEventOf(proposalTemplate()))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
val item = itemOver(unsignedEventOf(proposalTemplate()))
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
val artifact = MantraArtifact.fromArtifactEvent(
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
chatRoomId
)
assertEquals(session.eventId, artifact?.id)
assertEquals(item.eventId, artifact?.id)
}
@Test
@@ -0,0 +1,98 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* How a proposal's events go on the wire, and what an old build makes of them.
*
* The whole reason this encoding has two shapes rather than one is
* compatibility, and compatibility is exactly the thing no other test in this
* area can see: a build that predates batching is not here to be run against.
* So the property is asserted from the other side -- a batch of one is byte for
* byte the JSON object it always was, which is the only form such a build can
* read.
*/
class FrostProposalCodecTest {
private fun event(content: String, kind: Int = 1) = Event(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1_700_000_000L,
kind = kind,
tags = arrayOf(arrayOf("alt", "test")),
content = content,
sig = ""
)
@Test
fun `a batch of one is the bare event, exactly as it always was`() {
val one = event("just this")
// The load-bearing assertion. A device on a build that predates batching
// parses this with Event.fromJsonOrNull; anything but the bare object
// returns null there and the proposal is dropped, so every single-event
// session in a mixed-version group would stop working.
assertEquals(one.toJson(), FrostSigningEvents.encodeProposal(listOf(one)))
}
@Test
fun `a real batch is an array`() {
val encoded = FrostSigningEvents.encodeProposal(
listOf(event("first"), event("second"))
)
assertTrue(encoded.startsWith("["), "a batch must be a JSON array")
assertTrue(encoded.endsWith("]"))
}
@Test
fun `what is encoded is what comes back, in order`() {
val events = listOf(event("first"), event("second"), event("third"))
val decoded = FrostSigningEvents.decodeProposal(
FrostSigningEvents.encodeProposal(events)
)
// Order is the batch's index, and every join of nonces and partial
// signatures is positional against it.
assertEquals(
events.map { it.content },
decoded?.map { it.content }
)
}
@Test
fun `a bare event decodes as a batch of one, forever`() {
// Proposals in the old shape do not stop arriving just because this build
// stopped writing them.
val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson())
assertEquals(1, decoded?.size)
assertEquals("from an older build", decoded?.single()?.content)
}
@Test
fun `an array with one unreadable element is refused whole`() {
// Not shortened. The batch's length is what every later payload is checked
// against, so a proposal that quietly lost an event would have every
// signer's contribution rejected for being the wrong size.
val encoded = """[${event("fine").toJson()},{"not":"an event"}]"""
assertNull(FrostSigningEvents.decodeProposal(encoded))
}
@Test
fun `an empty array is not a proposal`() {
assertNull(FrostSigningEvents.decodeProposal("[]"))
}
@Test
fun `nonsense is not a proposal`() {
assertNull(FrostSigningEvents.decodeProposal("[this is not json"))
assertNull(FrostSigningEvents.decodeProposal(""))
assertNull(FrostSigningEvents.decodeProposal("null"))
}
}
@@ -8,6 +8,7 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
@@ -81,6 +82,7 @@ class FrostSigningSessionDaoJvmTest {
createdAt: Instant = Instant.fromEpochSeconds(1_000),
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
signature: String? = null,
events: Int = 1,
): FrostSigningSession = FrostSigningSession(
id = id,
chatRoomId = chatRoomId,
@@ -91,12 +93,33 @@ class FrostSigningSessionDaoJvmTest {
participantCount = 3,
signerId = 1,
stage = stage,
unsignedEventJson = "{}",
eventId = "ff".repeat(32),
nonceRandom = "aa".repeat(32),
signature = signature,
createdAt = createdAt,
).also { db.frostSigningSessionDao().upsert(it) }
).also {
db.frostSigningSessionDao().upsert(it)
db.frostSigningSessionDao().upsertItems(
(0 until events).map { index -> item(id, index, signature) }
)
}
/**
* One event of a session's batch. Distinct ids and seeds per index, because
* that is what the manager writes and what every ordering assertion here
* would otherwise be blind to.
*/
private fun item(
sessionId: String,
itemIndex: Int = 0,
signature: String? = null,
aggregatedNonce: String? = null,
) = FrostSigningItem(
sessionId = sessionId,
itemIndex = itemIndex,
unsignedEventJson = """{"index":$itemIndex}""",
eventId = "f$itemIndex".repeat(32),
nonceRandom = "a$itemIndex".repeat(32),
aggregatedNonce = aggregatedNonce,
signature = signature,
)
private suspend fun message(
sessionId: String,
@@ -242,6 +265,88 @@ class FrostSigningSessionDaoJvmTest {
val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1"))
assertEquals(FrostSigningStage.COMPLETE, found.stage)
assertEquals("ab".repeat(32), found.signature)
assertEquals("ab".repeat(32), db.frostSigningSessionDao().getItems("s1").single().signature)
}
/**
* A batch's items come back in the order the proposal fixed, whatever order
* they went in.
*
* Not a presentation detail. Nonces and partial signatures are joined
* positionally against this list, so a device reading a batch in a different
* order aggregates item 2's partial signature against item 0's message and
* produces a signature nobody can verify.
*/
@Test
fun `a batch's items read back in index order`() = runBlocking {
seedRooms()
session("s1")
db.frostSigningSessionDao().upsertItems(
listOf(item("s1", itemIndex = 3), item("s1", itemIndex = 1), item("s1", itemIndex = 2))
)
val items = db.frostSigningSessionDao().getItems("s1")
assertEquals(listOf(0, 1, 2, 3), items.map { it.itemIndex })
assertEquals(4, db.frostSigningSessionDao().countItems("s1"))
}
/**
* A redelivered item overwrites rather than accumulates, which is what the
* composite key buys — the same property the signer messages rely on, and for
* the same reason: a second row would make the batch the wrong length, and
* the length is what every payload is checked against.
*/
@Test
fun `a second write of one item replaces it`() = runBlocking {
seedRooms()
session("s1")
db.frostSigningSessionDao().upsert(item("s1", signature = "cd".repeat(32)))
val items = db.frostSigningSessionDao().getItems("s1")
assertEquals(1, items.size)
assertEquals("cd".repeat(32), items.single().signature)
}
/** Counted rather than flagged, so "the group has finished" cannot disagree with the rows. */
@Test
fun `signed items are counted apart from the rest`() = runBlocking {
seedRooms()
session("s1", events = 3)
assertEquals(3, db.frostSigningSessionDao().countItems("s1"))
assertEquals(0, db.frostSigningSessionDao().countSignedItems("s1"))
db.frostSigningSessionDao().upsertItems(
listOf(item("s1", 0, signature = "ab".repeat(32)), item("s1", 1, signature = "cd".repeat(32)))
)
assertEquals(2, db.frostSigningSessionDao().countSignedItems("s1"))
}
/** Items belong to their session and go with it, like the signer messages do. */
@Test
fun `deleting a room takes its sessions' items with it`() = runBlocking {
seedRooms()
session("s1", chatRoomId = roomOne, events = 2)
session("s2", chatRoomId = roomTwo, events = 2)
db.chatRoomDao().delete(assertNotNull(db.chatRoomDao().findChatRoomById(roomOne)).chatRoom)
assertEquals(emptyList(), db.frostSigningSessionDao().getItems("s1"))
assertEquals(2, db.frostSigningSessionDao().getItems("s2").size)
}
/** The single-item read the manager uses to pick one event out of a batch. */
@Test
fun `an item can be read by its index`() = runBlocking {
seedRooms()
session("s1", events = 3)
assertEquals("f2".repeat(32), db.frostSigningSessionDao().getItem("s1", 2)?.eventId)
assertNull(db.frostSigningSessionDao().getItem("s1", 3))
}
}
@@ -0,0 +1,186 @@
package press.mantra.compose.database.migrations
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The v9 -> v10 backfill, against a real database holding a real half-finished
* session.
*
* The stake is higher than a migration test usually carries. A session in flight
* at upgrade time holds `nonceRandom`, the seed its secret nonce is regenerated
* from, and `aggregatedNonce`, the aggregate its partial signature is made
* against. If either fails to arrive at item 0, the session's next pass derives a
* *different* nonce for the same message and publishes a second partial signature
* over it -- two partial signatures, one message, two nonces, which is how a
* secret share is extracted. So this asserts the values, not merely that a row
* appeared.
*
* Run against the migration's own SQL on a bare connection rather than through
* Room. That covers the copy and the shape it leaves behind, which is what this
* migration is; it does not cover Room's version wiring, which belongs to
* `PlatformDatabaseBuilder` and is the same for every migration in that list.
*/
class FrostSigningItemMigrationJvmTest {
private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:")
@AfterTest
fun close() = connection.close()
/** v9's `FrostSigningSession`, verbatim from `schemas/9.json`. */
private fun createV9() {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `FrostSigningSession` (`id` TEXT NOT NULL, " +
"`chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, " +
"`userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, " +
"`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " +
"`signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, " +
"`unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, " +
"`nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, " +
"`signature` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, " +
"`approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " +
"`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
"FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` " +
"ON `FrostSigningSession` (`chatRoomId`)"
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` " +
"ON `FrostSigningSession` (`dkgSessionId`)"
)
}
/** A session mid-flight: it has its seed, and the aggregate it is signing against. */
private fun insertV9Session(
id: String = "s1",
aggregatedNonce: String? = null,
signature: String? = null,
) = connection.execSQL(
"INSERT INTO `FrostSigningSession` VALUES (" +
"'$id', 'room', 'coord', 'user', 'dkg-1', 2, 3, 1, 'm/9420/0/0', 'COLLECTING_NONCES', " +
"'$unsignedEventJson', '$eventId', '$nonceRandom', " +
"${aggregatedNonce.orNull()}, '0,1', ${signature.orNull()}, " +
"NULL, 1, 1, 1000, 1000, 1000)"
)
private fun String?.orNull(): String = this?.let { "'$it'" } ?: "NULL"
/** Column names in declaration order, which is what Room checks the table against. */
private fun columns(table: String): List<String> =
connection.prepare("PRAGMA table_info(`$table`)").use { statement ->
buildList { while (statement.step()) add(statement.getText(1)) }
}
/** One column of the one row, as text, or null when the column is null. */
private fun read(table: String, column: String): String? =
connection.prepare("SELECT `$column` FROM `$table`").use { statement ->
if (statement.step() && !statement.isNull(0)) statement.getText(0) else null
}
private val unsignedEventJson = """{"kind":1}"""
private val eventId = "ff".repeat(32)
private val nonceRandom = "aa".repeat(32)
private val aggregatedNonce = "bb".repeat(66)
private val signature = "cc".repeat(32)
@Test
fun `an in-flight session keeps the seed and the aggregate it is already signing against`() =
runBlocking {
createV9()
insertV9Session(aggregatedNonce = aggregatedNonce)
MIGRATION_9_10.migrate(connection)
// The two that cannot be regenerated. Losing either turns this
// session's next pass into a second partial signature over one message.
assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom"))
assertEquals(aggregatedNonce, read("FrostSigningItem", "aggregatedNonce"))
assertEquals("0", read("FrostSigningItem", "itemIndex"))
assertEquals(eventId, read("FrostSigningItem", "eventId"))
assertEquals(unsignedEventJson, read("FrostSigningItem", "unsignedEventJson"))
}
@Test
fun `a finished session keeps its signature`() = runBlocking {
createV9()
insertV9Session(aggregatedNonce = aggregatedNonce, signature = signature)
MIGRATION_9_10.migrate(connection)
assertEquals(signature, read("FrostSigningItem", "signature"))
}
/** A session that had not been aggregated yet arrives with nothing invented for it. */
@Test
fun `a session still collecting nonces gets an item with no aggregate`() = runBlocking {
createV9()
insertV9Session()
MIGRATION_9_10.migrate(connection)
assertEquals(null, read("FrostSigningItem", "aggregatedNonce"))
assertEquals(null, read("FrostSigningItem", "signature"))
assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom"))
}
/**
* The shape Room checks on the next open. A column left behind, or one
* missing, is refused at open time -- a crash on launch rather than a bug
* anybody gets to debug.
*/
@Test
fun `the tables are left with exactly the columns v10 declares`() = runBlocking {
createV9()
insertV9Session()
MIGRATION_9_10.migrate(connection)
assertEquals(
listOf(
"id", "chatRoomId", "coordinatorPublicKey", "userPublicKey", "dkgSessionId",
"threshold", "participantCount", "signerId", "derivationPath", "stage",
"signerIds", "failureReason", "signApprovedAt", "approvalRequestedAt",
"createdAt", "updatedAt", "savedAt",
),
columns("FrostSigningSession")
)
assertEquals(
listOf(
"sessionId", "itemIndex", "unsignedEventJson", "eventId",
"nonceRandom", "aggregatedNonce", "signature",
),
columns("FrostSigningItem")
)
}
/** `signerIds` is shared by every item of a batch, so it stays where it was. */
@Test
fun `the signer set stays on the session`() = runBlocking {
createV9()
insertV9Session()
MIGRATION_9_10.migrate(connection)
assertEquals("0,1", read("FrostSigningSession", "signerIds"))
}
/** An empty table migrates to an empty table rather than to one bad row. */
@Test
fun `a database with no sessions gets no items`() = runBlocking {
createV9()
MIGRATION_9_10.migrate(connection)
assertEquals(null, read("FrostSigningItem", "sessionId"))
}
}
@@ -0,0 +1,224 @@
package press.mantra.compose.managers
import androidx.room3.Room
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* What a group gets for choosing "robust": the key ceremony its room opens with.
*
* The quorum picked while creating the group has nothing in NIP-17 to govern --
* there is no group state to change, and a different member set is simply a
* different room -- so the only thing that can carry it is a t-of-n key the
* members generate together. Everything that ceremony needs is settled the
* moment the room exists, so `SelectChatRoomTypeViewModel.createNip17ChatRoom`
* opens one there and then rather than leaving it for somebody to find a button
* for. These are the properties that has to hold to.
*
* The proposal being the room's *first* event is not a nicety. Standing up a
* NIP-17 room sends nothing to anybody, so until something goes out the group
* exists on one device only; the proposal is what the other members hear the
* room from at all, which is why `NostrDao` builds the room from a DKG payload's
* p-tags on the way in.
*
* The view model itself is not exercised here -- it needs a wallet's key manager
* and a Compose runtime -- so what stays uncovered is the wiring: that the
* quorum reaching [ChillDkgRitualManager.proposeRitual] is the one the picker
* holds, and that a null session leaves the user on the creation screen.
*/
class RobustRoomKeyCeremonyTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
@AfterTest
fun closeDb() = db.close()
// Real keys throughout: the room id is derived by doing point work over the
// member set, and the host key each member is identified by for the ceremony
// is derived from their nostr secret. Hex filler exercises neither.
private val creator = KeyPair()
private val user = creator.pubKey.toHexKey()
private val creatorPrivateKey = creator.privKey!!
private val alice = KeyPair().pubKey.toHexKey()
private val bob = KeyPair().pubKey.toHexKey()
/** The quorum a three-member group is offered by default, and picks here. */
private val quorum = 2
private suspend fun seedProfile(publicKey: String) {
val nostrEventId = publicKey.take(63) + "f"
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = publicKey,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(
Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId)
)
}
/** The room the creation screen leaves behind for a robust group. */
private suspend fun robustRoom(): LocalChatRoom {
listOf(user, alice, bob).forEach { seedProfile(it) }
return assertNotNull(
db.nostrNip17Dao().createNip17ChatRoom(
userPublicKey = user,
participantPublicKeys = listOf(alice, bob),
subject = "Robust Group",
),
"createNip17ChatRoom returned null",
)
}
private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) =
ChillDkgRitualManager.proposeRitual(
database = db,
localChatRoom = room,
userPublicKey = user,
nostrPrivateKey = creatorPrivateKey,
threshold = threshold,
)
private suspend fun ritualPayloads(room: LocalChatRoom) =
db.giftWrapPayloadDao().getByChatRoomAndKinds(room.chatRoom.id, DkgRitualEvents.ALL.toList())
/** The room's lines in the order they were written, id being the insertion order. */
private suspend fun chatMessages(room: LocalChatRoom) =
db.chatMessageDao().getChatMessagesByChatRoomId(room.chatRoom.id)
.map { it.chatMessage }
.sortedBy { it.id }
/**
* The whole of the change: a robust room has nothing in it until the ceremony,
* and the ceremony is the first thing in it.
*/
@Test
fun `a robust room's first message is the key ceremony it opens with`() = runBlocking {
val room = robustRoom()
assertTrue(chatMessages(room).isEmpty(), "the room says nothing until something is sent")
assertTrue(ritualPayloads(room).isEmpty(), "and nothing has gone out yet")
val session = openCeremony(room)
assertEquals(
ChatMessage.TYPE_DKG_STARTED,
chatMessages(room).first().messageType,
"the first line in a robust room is its ceremony opening",
)
val proposal = ritualPayloads(room).first()
assertEquals(DkgRitualEvents.PROPOSAL, proposal.kind, "and the first event out is the proposal")
assertEquals(user, proposal.publicKey)
// What every receiver rebuilds the ceremony from: `n` is the p-tag set plus
// the sender, and `t` is the quorum the group was created on. Both are read
// off this event rather than off the receiver's own view of the room, which
// is the thing that drifts.
assertEquals(
setOf(user, alice, bob),
proposal.participantPTags().map { it.pubKey }.toSet(),
"the proposal carries the whole membership",
)
assertEquals(quorum, DkgRitualEvents.parseThreshold(proposal.tags))
assertEquals(session.id, DkgRitualEvents.parseSessionId(proposal.tags))
assertEquals(quorum, session.threshold, "the ceremony runs at the quorum that was picked")
assertEquals(3, session.participantCount)
assertEquals(user, session.coordinatorPublicKey, "the room's creator coordinates")
// Opening a ceremony is the act of agreeing to be in it, so this member is
// not asked again -- and their host key goes out with the proposal.
assertNotNull(session.hostKeyApprovedAt)
assertEquals(
1,
ritualPayloads(room).count { it.kind == DkgRitualEvents.HOST_KEY },
"the coordinator joins the ceremony it opened",
)
}
/**
* Creation is re-enterable -- the room id is derived from the member set, so the
* same group made twice is the same room -- and the ceremony has to be
* re-enterable with it. A second proposal is a second participant set for every
* member to reconcile, and the key the first one produced would be left with
* nothing pointing at it.
*/
@Test
fun `making the same group twice does not open a second ceremony`() = runBlocking {
val room = robustRoom()
val first = openCeremony(room)
val again = assertNotNull(
db.nostrNip17Dao().createNip17ChatRoom(
userPublicKey = user,
participantPublicKeys = listOf(alice, bob),
subject = "Robust Group",
)
)
assertEquals(room.chatRoom.id, again.chatRoom.id, "the same members are the same room")
// Asked for a different quorum this time: the running ceremony settled that
// question when it opened, and answers with itself rather than the ask.
val second = openCeremony(again, threshold = 3)
assertEquals(first.id, second.id)
assertEquals(quorum, second.threshold)
assertEquals(
1,
ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL },
"one proposal, not two",
)
assertEquals(
1,
chatMessages(room).count { it.messageType == ChatMessage.TYPE_DKG_STARTED },
"and the room is told once",
)
}
/**
* A collapsed ceremony leaves the group with no key and a room they can still
* talk in, which is exactly the group that should be able to try again. Failed
* is the one state that does not hold the room's ceremony slot.
*/
@Test
fun `a failed ceremony is replaced rather than handed back`() = runBlocking {
val room = robustRoom()
val abandoned = openCeremony(room)
db.dkgSessionDao().upsert(abandoned.copy(stage = DkgRitualStage.FAILED))
val replacement = openCeremony(room)
assertNotEquals(abandoned.id, replacement.id)
assertEquals(DkgRitualStage.COLLECTING_HOST_KEYS, replacement.stage)
assertEquals(
2,
ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL },
"the group proposes again",
)
}
}
@@ -14,11 +14,13 @@ import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgParticipantMessage
@@ -121,6 +123,13 @@ class SignedGroupKeyStateTest {
suspend fun session(sessionId: String): FrostSigningSession? =
db.frostSigningSessionDao().getSessionById(sessionId)
/** The events a session signs, in the order its proposal fixed. */
suspend fun items(sessionId: String) =
db.frostSigningSessionDao().getItems(sessionId)
/** The one event a session of one signs. */
suspend fun item(sessionId: String) = items(sessionId).single()
suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId)
}
@@ -275,7 +284,7 @@ class SignedGroupKeyStateTest {
val payload = Event.fromJson(proposal.content)
assertEquals(GroupKeyStateEvent.KIND, payload.kind)
assertEquals(thresholdPublicKey, payload.content)
assertEquals(session.eventId, payload.id)
assertEquals(creator.item(session.id).eventId, payload.id)
}
@Test
@@ -289,7 +298,7 @@ class SignedGroupKeyStateTest {
key = ceremonyOn(creator)
)
val payload = Event.fromJson(session.unsignedEventJson)
val payload = Event.fromJson(creator.item(session.id).unsignedEventJson)
// Signing runs at the room's derivation path, so the key the group signs
// as is the room's own id. Not the group's root key, which is what an
@@ -320,7 +329,7 @@ class SignedGroupKeyStateTest {
// Rebuilt from the event's own fields under this device's own reading of
// the room. Agreeing on the id is agreeing on every byte that is signed,
// the author included.
assertEquals(session.eventId, received.eventId)
assertEquals(creator.item(session.id).eventId, other.item(session.id).eventId)
assertEquals(session.derivationPath, received.derivationPath)
}
@@ -337,7 +346,12 @@ class SignedGroupKeyStateTest {
)
pump(creator, other)
assertTrue(FrostSigningManager.isAwaitingApproval(other.session(session.id)!!))
assertTrue(
FrostSigningManager.isAwaitingApproval(
other.session(session.id)!!,
other.items(session.id)
)
)
assertTrue(
outbox(other).isEmpty(),
"a device that has not been asked yet must publish nothing"
@@ -394,7 +408,7 @@ class SignedGroupKeyStateTest {
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
val signed = FrostSigningManager.signedEvent(creator.session(session.id)!!)
val signed = FrostSigningManager.signedEvent(creator.item(session.id))
assertNotNull(signed, "a completed session must carry a signed event")
assertEquals(adminRoomId, signed.pubKey)
@@ -446,6 +460,313 @@ class SignedGroupKeyStateTest {
assertEquals(adminRoomId, absent.keyState()?.announcedBy)
}
// ---- Signing several events in one session -----------------------------
/**
* The batch, end to end, between two devices over two databases: three
* events, one signer set, one approval, and three signatures that verify
* against the room.
*
* Everything about batching that could be wrong and still compile is wrong
* here or nowhere -- a shared nonce, a mismatched order, a payload split the
* wrong way. A signature that verifies is the only evidence any of it is
* wired up correctly, and there are three of them to disagree.
*/
@Test
fun `a batch of three is signed in one session`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
listOf(creator, other).forEach { device ->
val items = device.items(session.id)
assertEquals(3, items.size, "every device signs the whole batch")
assertEquals(FrostSigningStage.COMPLETE, device.session(session.id)?.stage)
items.forEach { item ->
assertTrue(
Nip01Crypto.verify(
signature = assertNotNull(item.signature).hexToByteArray(),
hash = item.eventId.hexToByteArray(),
pubKey = adminRoomId.hexToByteArray()
),
"item ${item.itemIndex} must verify against the room it was signed in"
)
}
}
}
/**
* The one that catches the mistake this whole design exists to prevent.
*
* Every positive test above still passes if two items share a nonce -- the
* signatures verify perfectly well. What sharing costs is the secret share,
* to anyone who sees both partial signatures. So the aggregates and the seeds
* are asserted pairwise distinct, which is cheap and is the only assertion
* here that an index bug cannot slip past.
*/
@Test
fun `no two events in a batch share a nonce`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
listOf(creator, other).forEach { device ->
val items = device.items(session.id)
assertEquals(3, items.mapNotNull { it.aggregatedNonce }.toSet().size)
assertEquals(3, items.map { it.nonceRandom }.toSet().size)
assertEquals(3, items.map { it.eventId }.toSet().size)
}
// A seed is this device's own, so the two devices must not have arrived
// at the same ones either.
assertEquals(
emptySet(),
creator.items(session.id).map { it.nonceRandom }.toSet()
.intersect(other.items(session.id).map { it.nonceRandom }.toSet())
)
}
/** Four messages for three events, which is the whole point of batching. */
@Test
fun `a batch costs one round of messages, not one per event`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
// Proposal, nonce, signer set, partial signature, signature.
assertEquals(
listOf(
FrostSigningEvents.PROPOSAL,
FrostSigningEvents.NONCE,
FrostSigningEvents.SIGNER_SET,
FrostSigningEvents.PARTIAL_SIGNATURE,
FrostSigningEvents.SIGNATURE,
),
outbox(creator).map { it.kind }
)
// The other member is a signer and not the coordinator: one nonce and one
// partial signature, each carrying all three values.
assertEquals(
listOf(FrostSigningEvents.NONCE, FrostSigningEvents.PARTIAL_SIGNATURE),
outbox(other).map { it.kind }
)
outbox(other).forEach { queued ->
assertEquals(3, queued.content.split(",").size, "kind ${queued.kind} must carry three values")
}
// And one question put to the member, not three.
assertEquals(
1,
other.db.chatMessageDao().getChatMessagesByChatRoomId(other.roomId)
.count { it.chatMessage.messageType == ChatMessage.TYPE_FROST_APPROVAL_NEEDED }
)
}
/** Every event of a batch is applied, not just the first. */
@Test
fun `every dialect in a batch lands on every device`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
listOf(creator, other).forEach { device ->
val names = device.items(session.id).map { item ->
assertNotNull(
device.db.mantraDialectDao().getDialectById(item.eventId),
"item ${item.itemIndex} should have been applied"
).name
}
assertEquals(listOf("Sepedi", "isiZulu", "Setswana"), names)
}
}
/**
* A proposal is the one place a remote party decides how much work everybody
* else does, so the size cap is checked on arrival and not only when
* proposing.
*/
@Test
fun `a batch larger than the cap is refused when proposed`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val tooMany = (0..FrostSigningManager.MAX_BATCH_SIZE).map { index ->
DialectEvent.build(name = "d$index", country = "ZA", language = "l$index")
}
assertFailsWith<IllegalArgumentException> {
FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = tooMany
)
}
Unit
}
/**
* A payload that is not the batch's length is left out, not truncated -- and
* leaving it out stalls the session rather than poisoning it.
*
* The length check is the only thing standing between a batch and a signer
* whose contribution lines up against the wrong messages. Truncating a long
* payload or padding a short one would produce partial signatures aggregated
* against events nobody agreed to.
*/
@Test
fun `a nonce payload of the wrong length is left out rather than truncated`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
// One nonce offered for a batch of three, from a member the coordinator
// would otherwise have picked.
FrostSigningManager.processSigningPayload(
database = creator.db,
localChatRoom = creator.room,
innerEvent = Event(
id = "1".repeat(64),
pubKey = other.publicKey,
createdAt = 1,
kind = FrostSigningEvents.NONCE,
tags = FrostSigningEvents.assembleTags(session.id),
content = "aa".repeat(66),
sig = ""
),
userPublicKey = creator.publicKey
)
assertNull(
creator.session(session.id)?.signerIds,
"a payload of the wrong length must not count towards the threshold"
)
// And it is a stall, not damage: the real nonce replaces it and the batch
// finishes. That is the composite key on FrostSignerMessage doing its job.
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
assertEquals(FrostSigningStage.COMPLETE, creator.session(session.id)?.stage)
assertEquals(3, creator.items(session.id).count { it.signature != null })
}
/**
* A second proposal under a session's own id cannot change what it signs.
*
* The rule the whole design rests on. Every item's nonce seed is already
* committed to that item's message; giving the session a different batch
* would have those seeds produce a second partial signature over a second
* message, which is how a secret share is extracted.
*/
@Test
fun `a second proposal under the same id cannot change what a session signs`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
val before = other.items(session.id)
val substitute = DialectEvent.build(name = "Xitsonga", country = "ZA", language = "tso")
FrostSigningManager.processSigningPayload(
database = other.db,
localChatRoom = other.room,
innerEvent = Event(
id = "2".repeat(64),
pubKey = creator.publicKey,
createdAt = 1,
kind = FrostSigningEvents.PROPOSAL,
tags = FrostSigningEvents.assembleTags(session.id, dkgSessionId = ceremonyId),
content = FrostSigningEvents.encodeProposal(
listOf(
Event(
id = "3".repeat(64),
pubKey = adminRoomId,
createdAt = substitute.createdAt,
kind = substitute.kind,
tags = substitute.tags,
content = substitute.content,
sig = ""
)
)
),
sig = ""
),
userPublicKey = other.publicKey
)
assertEquals(
before.map { it.eventId },
other.items(session.id).map { it.eventId },
"a re-proposal must be ignored, not applied"
)
assertEquals(before.map { it.nonceRandom }, other.items(session.id).map { it.nonceRandom })
}
/** Three dialects, distinct enough that an order bug shows up as a wrong name. */
private fun dialects() = listOf(
Triple("Sepedi", "ZA", "nso"),
Triple("isiZulu", "ZA", "zul"),
Triple("Setswana", "ZA", "tsn"),
).map { (name, country, language) ->
DialectEvent.build(name = name, country = country, language = language)
}
// ---- What a room signs as, once it has a key state ---------------------
@Test
@@ -467,7 +788,7 @@ class SignedGroupKeyStateTest {
pump(creator, other)
listOf(creator, other).forEach { device ->
val dialect = device.db.mantraDialectDao().getDialectById(session.eventId)
val dialect = device.db.mantraDialectDao().getDialectById(device.item(session.id).eventId)
assertNotNull(dialect, "a signed dialect should exist on every signer's device")
assertEquals("Sepedi", dialect.name)
// The ask this whole change serves: the group's work is authored by
@@ -601,7 +922,7 @@ class SignedGroupKeyStateTest {
)
assertEquals(SharedKeyDerivation.formatPath(sibling), session.derivationPath)
assertEquals(siblingRoomId, Event.fromJson(session.unsignedEventJson).pubKey)
assertEquals(siblingRoomId, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey)
pump(creator, other)
FrostSigningManager.approve(other.db, other.room, session.id)
@@ -640,6 +961,6 @@ class SignedGroupKeyStateTest {
assertNull(session.derivationPath, "no path reaches a room that was not derived")
assertEquals(emptyList(), session.pathIndices())
assertEquals(rootPublicKey, Event.fromJson(session.unsignedEventJson).pubKey)
assertEquals(rootPublicKey, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey)
}
}