From 8a6cb81bf919bfc0604dbb8d6f0fd9d0d06596b2 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 03:25:01 +0200 Subject: [PATCH] test: cover the FROST signing session state The signing counterpart to the DKG coverage, and it differs in the way the DAO's own comment gives: "unlike a DKG a group signs repeatedly, so there is no single current one to observe". Sessions accumulate rather than replacing each other, which makes room scoping and ordering load-bearing rather than incidental. The duplicate-suppression property is the same and matters for the same reason. The composite key (sessionId, signerPublicKey, kind) is what makes a redelivered nonce or partial signature replace its predecessor rather than add a row, and countMessagesByKind is what decides that enough signers have answered. A second row for one signer lets a session cross its threshold while short a real participant, and the aggregation then runs over a signer set that was never assembled. Covered by resending a nonce with a different payload, and separately by giving one signer both a nonce and a partial signature and asserting the second does not overwrite the first -- the kind in the key is the only thing keeping those apart. Also covered: a session reads back by id with its stage intact and a missing id gives null; a room's sessions accumulate newest first, with the latest reachable on its own; sessions are scoped to their room, which matters because signing happens in the #admins room and a device can be in more than one -- a session leaking across would have a signer answering a request its group never made; counts are per session and per round; and a completed session keeps its signature, which is what a resume reads to avoid signing the same event twice. One test records a difference rather than a guarantee. FROST orders a round's messages by createdAt where the DKG orders the same query by participant public key. Arrival order is per-device, so this ordering is not canonical across the group the way the DKG's is. It is pinned as it stands rather than asserted to be right: whether it is deliberate is not something this change can settle, and a caller that needs a canonical signer order has to impose one itself. Worth looking at separately. 8 tests. Co-Authored-By: Claude Opus 5 --- .../dao/FrostSigningSessionDaoJvmTest.kt | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt new file mode 100644 index 00000000..2e03b6f7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt @@ -0,0 +1,247 @@ +package press.mantra.compose.database.dao + +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.ChatRoom +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.types.FrostSigningStage +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The signing counterpart to [DkgSessionDaoJvmTest], and it differs from the DKG in one way + * that shapes every query here: "unlike a DKG a group signs repeatedly, so there is no single + * current one to observe". Sessions accumulate, which makes room scoping and ordering + * load-bearing rather than incidental. + * + * The duplicate-suppression property is the same and matters for the same reason. The composite + * key (sessionId, signerPublicKey, kind) is what makes a redelivered nonce or partial signature + * replace its predecessor instead of adding a row, and `countMessagesByKind` is what decides + * that enough signers have answered. A second row for one signer would let a session cross its + * threshold while short a real participant. + */ +class FrostSigningSessionDaoJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val user = KeyPair().pubKey.toHexKey() + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + private val roomOne = "11".repeat(32) + private val roomTwo = "22".repeat(32) + + private val nonceKind = 1 + private val partialSignatureKind = 2 + + private suspend fun seedRooms() { + val nostrEventId = "c".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = user, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId)) + listOf(roomOne, roomTwo).forEach { id -> + db.chatRoomDao().upsert( + ChatRoom( + id = id, + userPublicKey = user, + subject = null, + description = null, + mlsGroupState = null, + ) + ) + } + } + + private suspend fun session( + id: String, + chatRoomId: String = roomOne, + createdAt: Instant = Instant.fromEpochSeconds(1_000), + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + signature: String? = null, + ): FrostSigningSession = FrostSigningSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = user, + userPublicKey = user, + dkgSessionId = "dkg-1", + threshold = 2, + participantCount = 3, + signerId = 1, + stage = stage, + unsignedEventJson = "{}", + eventId = "ff".repeat(32), + nonceRandom = "aa".repeat(32), + signature = signature, + createdAt = createdAt, + ).also { db.frostSigningSessionDao().upsert(it) } + + private suspend fun message( + sessionId: String, + signer: String, + kind: Int = nonceKind, + payload: String = "aa".repeat(32), + createdAt: Instant = Instant.fromEpochSeconds(1_000), + ) = db.frostSigningSessionDao().upsert( + FrostSignerMessage( + sessionId = sessionId, + signerPublicKey = signer, + kind = kind, + payload = payload, + createdAt = createdAt, + ) + ) + + @Test + fun `a signing session reads back by its id`() = runBlocking { + seedRooms() + session("s1", stage = FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) + + val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1")) + + assertEquals(FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES, found.stage) + assertNull(db.frostSigningSessionDao().getSessionById("nope")) + } + + /** + * Sessions accumulate rather than replacing each other, so a room keeps a history and the + * newest is the one a resume cares about. + */ + @Test + fun `a rooms signing sessions accumulate newest first`() = runBlocking { + seedRooms() + session("first", createdAt = Instant.fromEpochSeconds(1_000)) + session("second", createdAt = Instant.fromEpochSeconds(2_000)) + session("third", createdAt = Instant.fromEpochSeconds(3_000)) + + assertEquals( + listOf("third", "second", "first"), + db.frostSigningSessionDao().getSessionsForChatRoom(roomOne).map { it.id }, + ) + assertEquals("third", db.frostSigningSessionDao().getLatestSessionForChatRoom(roomOne)?.id) + } + + /** + * Signing happens in the #admins room, and a device can be in more than one. A session from + * another room appearing here would have a signer answering a request its group never made. + */ + @Test + fun `sessions are scoped to their own room`() = runBlocking { + seedRooms() + session("mine", chatRoomId = roomOne) + session("theirs", chatRoomId = roomTwo, createdAt = Instant.fromEpochSeconds(9_000)) + + assertEquals(listOf("mine"), db.frostSigningSessionDao().getSessionsForChatRoom(roomOne).map { it.id }) + assertEquals("mine", db.frostSigningSessionDao().getLatestSessionForChatRoom(roomOne)?.id) + assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom("33".repeat(32))) + } + + /** + * The threshold gate. A redelivered nonce must replace the signer's earlier one rather than + * add a row, or the count crosses the threshold with fewer signers than the session + * requires -- and the aggregation proceeds on a set that was never assembled. + */ + @Test + fun `a resent nonce replaces the signers earlier one`() = runBlocking { + seedRooms() + session("s1") + message("s1", alice, payload = "aa".repeat(32)) + + message("s1", alice, payload = "bb".repeat(32)) + + assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s1", nonceKind)) + assertEquals( + "bb".repeat(32), + db.frostSigningSessionDao().getMessage("s1", nonceKind, alice)?.payload, + ) + } + + /** + * A signer contributes to both rounds of a session -- a nonce and then a partial signature + * -- and the kind in the composite key is what keeps the second from overwriting the first. + */ + @Test + fun `a signer holds a nonce and a partial signature at once`() = runBlocking { + seedRooms() + session("s1") + message("s1", alice, kind = nonceKind, payload = "aa".repeat(32)) + message("s1", alice, kind = partialSignatureKind, payload = "cc".repeat(32)) + + assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s1", nonceKind)) + assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s1", partialSignatureKind)) + assertEquals( + "aa".repeat(32), + db.frostSigningSessionDao().getMessage("s1", nonceKind, alice)?.payload, + "the partial signature overwrote the nonce", + ) + } + + @Test + fun `messages are counted per session and per round`() = runBlocking { + seedRooms() + session("s1") + session("s2") + message("s1", alice, kind = nonceKind) + message("s1", bob, kind = nonceKind) + message("s2", alice, kind = nonceKind) + + assertEquals(2, db.frostSigningSessionDao().countMessagesByKind("s1", nonceKind)) + assertEquals(1, db.frostSigningSessionDao().countMessagesByKind("s2", nonceKind)) + assertEquals(0, db.frostSigningSessionDao().countMessagesByKind("s1", partialSignatureKind)) + assertNull(db.frostSigningSessionDao().getMessage("s1", nonceKind, user)) + } + + /** + * Worth recording because it is the one place these two DAOs disagree: FROST orders a + * round's messages by `createdAt`, where the DKG orders the same query by participant + * public key. Arrival order is per-device, so this ordering is not canonical across the + * group the way the DKG's is. Pinned as it stands rather than assumed to be either + * deliberate or a slip -- a caller that needs a canonical signer order has to impose one. + */ + @Test + fun `a rounds messages are ordered by arrival, unlike the dkg`() = runBlocking { + seedRooms() + session("s1") + val byKey = listOf(alice, bob).sorted() + message("s1", byKey[1], createdAt = Instant.fromEpochSeconds(1_000)) + message("s1", byKey[0], createdAt = Instant.fromEpochSeconds(2_000)) + + val found = db.frostSigningSessionDao().getMessagesByKind("s1", nonceKind).map { it.signerPublicKey } + + assertEquals(listOf(byKey[1], byKey[0]), found, "FROST returns a round in arrival order") + } + + /** A finished session keeps its signature, which is what a resume reads to avoid re-signing. */ + @Test + fun `a completed session keeps its signature`() = runBlocking { + seedRooms() + session("s1", stage = FrostSigningStage.COMPLETE, signature = "ab".repeat(32)) + + val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1")) + + assertEquals(FrostSigningStage.COMPLETE, found.stage) + assertEquals("ab".repeat(32), found.signature) + } +}