diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt index f0a5a1c7..5370123e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt @@ -3,6 +3,7 @@ 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.flow.first import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase @@ -15,6 +16,7 @@ import press.mantra.compose.database.model.types.DkgRitualStage import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.time.Instant @@ -85,6 +87,8 @@ class DkgSessionDaoJvmTest { secretShare: String? = null, stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS, userPublicKey: String = user, + parentChatRoomId: String? = null, + participantPublicKeys: String? = null, ): DkgSession = DkgSession( id = id, chatRoomId = chatRoomId, @@ -98,6 +102,8 @@ class DkgSessionDaoJvmTest { round2AuxRandom = "bb".repeat(32), thresholdPublicKey = thresholdPublicKey, secretShare = secretShare, + parentChatRoomId = parentChatRoomId, + participantPublicKeys = participantPublicKeys, createdAt = createdAt, ).also { db.dkgSessionDao().upsert(it) } @@ -293,4 +299,140 @@ class DkgSessionDaoJvmTest { assertEquals(1, db.dkgSessionDao().countMessagesByKind("s2", user, hostKeyKind)) assertNull(db.dkgSessionDao().getMessage("s2", user, round1Kind, alice)) } + + /** + * The one read that names no profile, and the property it was added for. + * + * `completedKey` and everything around it is scoped because a row carries secrets. + * This projects the ceremony's published result, which is the same value on every + * participant's row, and two screens want it to work out which room a ceremony's + * key derives. Scoping it would have them resolve nothing while no profile is open, + * for a fact that does not depend on one. + */ + @Test + fun `a ceremonys key answers whichever profile asks`() = runBlocking { + seedRooms() + val key = "dd".repeat(32) + session("shared", thresholdPublicKey = key, secretShare = "ee".repeat(32), userPublicKey = alice) + + assertEquals( + key, + db.dkgSessionDao().getCeremonyThresholdPublicKey("shared"), + "the profile that holds the row gets the key", + ) + + // The point of it. Bob holds no row of this ceremony at all, and still gets the + // key -- which is what every member of the group has anyway. + session("bobs-own", thresholdPublicKey = "aa".repeat(32), userPublicKey = bob) + assertEquals( + key, + db.dkgSessionDao().getCeremonyThresholdPublicKey("shared"), + "and so does a profile holding no row of it", + ) + } + + /** A ceremony with no key yet answers null rather than throwing: both callers run before it finishes. */ + @Test + fun `a ceremony that has produced no key answers null`() = runBlocking { + seedRooms() + session("running", thresholdPublicKey = null) + + assertNull(db.dkgSessionDao().getCeremonyThresholdPublicKey("running")) + assertNull(db.dkgSessionDao().getCeremonyThresholdPublicKey("no-such-ceremony")) + } + + /** + * Which subgroup a room is already making, for one profile. + * + * The predicate matters because the answer is a refusal: `proposeRitual` folds into + * a ceremony this finds rather than opening another, and `refuseSubgroup` turns it + * into a message. A ritual the *other* profile on the device is running is not one + * this person can answer for, so finding it would refuse them a ceremony of their + * own with a notice about somebody else's. + */ + @Test + fun `a subgroup ceremony is found only by the profile running it`() = runBlocking { + seedRooms() + val admins = DkgSession.formatParticipants(setOf(alice, bob, user)).orEmpty() + session( + "alices-subgroup", + chatRoomId = roomOne, + parentChatRoomId = roomOne, + participantPublicKeys = admins, + userPublicKey = alice, + ) + + assertEquals( + "alices-subgroup", + db.dkgSessionDao().getLatestSubgroupSessionFor(roomOne, roomOne, admins, alice)?.id, + ) + assertNull( + db.dkgSessionDao().getLatestSubgroupSessionFor(roomOne, roomOne, admins, bob), + "the other profile is running nothing and must be refused nothing", + ) + assertNull( + db.dkgSessionDao().getLatestSubgroupSessionFor(roomOne, roomOne, "other,admins", alice), + "and a different admin set is a different subgroup", + ) + } + + /** + * The room-and-purpose read the ceremony screen watches, scoped the same way: a + * room holds its own ceremony and a subgroup's at once, and now one of each per + * profile. + */ + @Test + fun `the watched ceremony for a purpose is the asking profiles`() = runBlocking { + seedRooms() + session("alices-own", userPublicKey = alice) + session("bobs-own", userPublicKey = bob) + session("alices-child", parentChatRoomId = roomTwo, userPublicKey = alice) + + assertEquals( + "alices-own", + db.dkgSessionDao().observeLatestSessionFor(roomOne, null, alice).first()?.id, + ) + assertEquals( + "bobs-own", + db.dkgSessionDao().observeLatestSessionFor(roomOne, null, bob).first()?.id, + ) + assertEquals( + "alices-child", + db.dkgSessionDao().observeLatestSessionFor(roomOne, roomTwo, alice).first()?.id, + "and the subgroup's is told apart from the room's own", + ) + assertNull( + db.dkgSessionDao().observeLatestSessionFor(roomOne, roomTwo, bob).first(), + "which the other profile is not running", + ) + } + + /** The transcript's view of a ritual's messages, which is one profile's view. */ + @Test + fun `the watched messages are the asking profiles`() = runBlocking { + seedRooms() + session("s1", userPublicKey = alice) + session("s1-bob", userPublicKey = bob) + message("s1", alice, userPublicKey = alice) + message("s1", bob, userPublicKey = alice) + message("s1-bob", bob, userPublicKey = bob) + + assertEquals( + listOf(alice, bob).sorted(), + db.dkgSessionDao().observeMessages("s1", alice).first() + .map { it.participantPublicKey } + .sorted(), + "Alice's view holds both messages she received", + ) + assertEquals( + emptyList(), + db.dkgSessionDao().observeMessages("s1", bob).first(), + "and Bob has received none of that ritual's", + ) + assertEquals( + 1, + assertNotNull(db.dkgSessionDao().observeMessages("s1-bob", bob).first()).size, + ) + } + } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt new file mode 100644 index 00000000..1d97cf18 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionMigrationThroughRoomJvmTest.kt @@ -0,0 +1,286 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.Room +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import java.io.File +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase + +/** + * The half of a migration a bare-connection test cannot reach: Room's own wiring. + * + * [DkgSessionPerProfileMigrationJvmTest] drives `MIGRATION_21_22`'s SQL directly, + * which covers the copy and the shape it leaves behind. What it cannot cover is + * whether that shape is the one Room expects. On opening a file at an older version + * Room runs the migrations and then compares every table against its entity, failing + * with `Migration didn't properly handle: …` when they differ. A migration whose DDL + * has drifted from its `@Entity` fails there and nowhere else. + * + * Nothing in this repository exercised that path before. The three existing migration + * tests all stop at the SQL, and `MantraDatabaseJvmTest` only ever opens a fresh + * database at the current version, where no migration runs at all. So a mismatch + * between `DkgSessionPerProfileMigration`'s `CREATE TABLE` strings and the two + * entities would have been caught by nothing until an upgrade on a real device. + * + * ### What it does and does not catch + * + * Measured rather than assumed, by drifting the migration and watching: + * + * - **a column dropped from the rebuilt table fails all three tests**, which is the + * drift that actually happens — somebody adds a field to the entity and does not + * carry it into the `CREATE TABLE` here; + * - **an index renamed does not fail anything.** Room's comparison is lenient about + * index names, so the rebuilt index name is on nobody's authority but the + * migration's. `DkgSessionPerProfileMigrationJvmTest` pins it by string against the + * schema export instead, which is the only thing that holds it. + * + * Worth knowing before trusting this to cover the next migration: it covers the + * columns, the types and the key, and not the index names. + * + * ### How the old file is built + * + * From `schemas/21.json`, the schema export committed beside its predecessors, rather + * than from DDL copied into this file. Fifty-five tables is too many to restate, and + * restating them is the thing that would rot: the export *is* what a v21 device has, + * and reading it means this test cannot drift from it. `workingDir` is the module + * directory, which is what makes the relative path work. + * + * `room_master_table` and `user_version` are set by hand because they are what Room + * reads to decide there is anything to do. + */ +class DkgSessionMigrationThroughRoomJvmTest { + + private val databaseFile: File = + File.createTempFile("dkg-per-profile-migration", ".db").also { it.delete() } + + private var database: MantraDatabase? = null + + @AfterTest + fun cleanUp() { + database?.close() + // SQLite leaves these beside the file when it is closed less than cleanly. + listOf("", "-wal", "-shm").forEach { File(databaseFile.path + it).delete() } + } + + private val alice = "0a".repeat(32) + private val bob = "0b".repeat(32) + private val roomId = "11".repeat(32) + private val secretShare = "cc".repeat(32) + private val round1Random = "aa".repeat(32) + private val hostKeyPayload = "01".repeat(33) + + /** The exported schema of a version, as a v21 device actually holds it. */ + private fun schema(version: Int): SchemaExport { + val candidates = listOf( + File("schemas/press.mantra.compose.database.MantraDatabase/$version.json"), + File("composeApp/schemas/press.mantra.compose.database.MantraDatabase/$version.json"), + ) + val file = candidates.firstOrNull { it.isFile } + ?: error( + "No schema export for version $version. Looked in " + + candidates.joinToString { it.absolutePath } + ) + return SchemaExport(file.readText()) + } + + /** + * The three things wanted out of an export: the identity hash, and every table's + * and index's `CREATE` statement with Room's placeholder resolved. + * + * Walked as a tree rather than matched with a regex, because the placeholder is + * resolved per entity and pairing `createSql` to `tableName` by position in the + * text is the kind of thing that works until a schema export changes shape. + */ + private class SchemaExport(json: String) { + private val database = Json.parseToJsonElement(json).jsonObject + .getValue("database").jsonObject + + val identityHash: String = + database.getValue("identityHash").jsonPrimitive.content + + val createStatements: List = buildList { + database.getValue("entities").jsonArray.forEach { entity -> + val table = entity.jsonObject.getValue("tableName").jsonPrimitive.content + fun resolve(element: JsonElement) = + element.jsonPrimitive.content.replace("${'$'}{TABLE_NAME}", table) + + add(resolve(entity.jsonObject.getValue("createSql"))) + entity.jsonObject["indices"]?.jsonArray?.forEach { index -> + add(resolve(index.jsonObject.getValue("createSql"))) + } + } + database["views"]?.jsonArray?.forEach { view -> + add(view.jsonObject.getValue("createSql").jsonPrimitive.content) + } + } + } + + /** Stands a v21 file up on disk, holding one finished ceremony and one message. */ + private fun createV21Database() { + val export = schema(21) + val connection = BundledSQLiteDriver().open(databaseFile.absolutePath) + try { + connection.execSQL("PRAGMA foreign_keys = OFF") + export.createStatements.forEach { connection.execSQL(it) } + + // What Room reads to know whose schema this is and how old it is. + connection.execSQL( + "CREATE TABLE IF NOT EXISTS room_master_table " + + "(id INTEGER PRIMARY KEY, identity_hash TEXT)" + ) + connection.execSQL( + "INSERT OR REPLACE INTO room_master_table (id, identity_hash) " + + "VALUES(42, '${export.identityHash}')" + ) + connection.execSQL("PRAGMA user_version = 21") + + seedV21Rows(connection) + } finally { + connection.close() + } + } + + /** A ceremony worth not losing, with the rows its foreign keys need. */ + private fun seedV21Rows(connection: SQLiteConnection) { + connection.execSQL( + "INSERT INTO `NostrEvent` (`id`, `pubKey`, `kind`, `tags`, `content`, `sig`, " + + "`createdAt`, `updatedAt`, `savedAt`) VALUES " + + "('${"f".repeat(64)}', '$alice', 0, '[]', '{}', '${"0".repeat(128)}', 1000, 1000, 1000)" + ) + connection.execSQL( + "INSERT INTO `Profile` (`publicKey`, `nostrEventId`, `createdAt`, `updatedAt`, `savedAt`) " + + "VALUES ('$alice', '${"f".repeat(64)}', 1000, 1000, 1000)" + ) + connection.execSQL( + "INSERT INTO `ChatRoom` (`id`, `userPublicKey`, `createdAt`, `updatedAt`, `savedAt`) " + + "VALUES ('$roomId', '$alice', 1000, 1000, 1000)" + ) + connection.execSQL( + "INSERT INTO `DkgSession` (`id`, `chatRoomId`, `coordinatorPublicKey`, " + + "`userPublicKey`, `threshold`, `participantCount`, `stage`, `hostPublicKey`, " + + "`round1Random`, `round2AuxRandom`, `thresholdPublicKey`, `secretShare`, " + + "`createdAt`, `updatedAt`, `savedAt`) VALUES " + + "('ceremony-1', '$roomId', '$alice', '$alice', 2, 3, 'COMPLETE', " + + "'$hostKeyPayload', '$round1Random', '${"bb".repeat(32)}', " + + "'02${"ab".repeat(32)}', '$secretShare', 1000, 1000, 1000)" + ) + connection.execSQL( + "INSERT INTO `DkgParticipantMessage` " + + "(`sessionId`, `participantPublicKey`, `kind`, `payload`, `createdAt`) " + + "VALUES ('ceremony-1', '$bob', 30450, '$hostKeyPayload', 1000)" + ) + } + + /** Opens the file the way the app does, which is what runs the migration. */ + private fun openThroughRoom(): MantraDatabase = + getRoomDatabase(Room.databaseBuilder(name = databaseFile.absolutePath)) + .also { database = it } + + private fun readOnce(sql: String, read: (androidx.sqlite.SQLiteStatement) -> T): T? { + val connection = BundledSQLiteDriver().open(databaseFile.absolutePath) + return try { + connection.prepare(sql).use { if (it.step()) read(it) else null } + } finally { + connection.close() + } + } + + /** + * The assertion that costs nothing to write and would have caught a drifted + * `CREATE TABLE`: Room opens the file, migrates it, and validates what the + * migration left against the entities. Reaching a readable database at all is the + * result. + */ + @Test + fun `Room migrates a v21 file and validates what the migration left`() = runBlocking { + createV21Database() + + val db = openThroughRoom() + + // Room is lazy: the file is not touched until something is asked of it, so the + // migration and its schema validation both happen inside this call. + val session = assertNotNull( + db.dkgSessionDao().getSessionById("ceremony-1", alice), + "the ceremony has to survive the upgrade and be readable under the new key", + ) + + assertEquals(alice, session.userPublicKey) + assertEquals(secretShare, session.secretShare, "the share came through verbatim") + assertEquals(round1Random, session.round1Random) + + // And the message, now owned by the profile whose session it hung off. + val message = assertNotNull( + db.dkgSessionDao().getMessage("ceremony-1", alice, 30450, bob), + "the cascade must not have taken it during the rebuild", + ) + assertEquals(alice, message.userPublicKey) + assertEquals(hostKeyPayload, message.payload) + } + + /** Room records the upgrade, rather than quietly recreating the file. */ + @Test + fun `the file is left at version 22 with the new identity`() = runBlocking { + createV21Database() + + openThroughRoom().dkgSessionDao().getSessionById("ceremony-1", alice) + database?.close() + database = null + + assertEquals(22, readOnce("PRAGMA user_version") { it.getInt(0) }) + assertEquals( + schema(22).identityHash, + readOnce("SELECT identity_hash FROM room_master_table WHERE id = 42") { it.getText(0) }, + "Room stamps the schema it validated, so this is its own verdict on the migration", + ) + } + + /** + * The upgraded file takes a second profile's row for the same ceremony, which a + * v21 file could not hold at all — the point of the migration, asserted through + * the DAO rather than through the rebuilt DDL. + */ + @Test + fun `the upgraded file holds a second profiles row for one ceremony`() = runBlocking { + createV21Database() + + val db = openThroughRoom() + val alices = assertNotNull(db.dkgSessionDao().getSessionById("ceremony-1", alice)) + + db.dkgSessionDao().upsert( + alices.copy( + userPublicKey = bob, + hostPublicKey = "02".repeat(33), + round1Random = "dd".repeat(32), + secretShare = "ee".repeat(32), + ) + ) + + assertEquals( + secretShare, + db.dkgSessionDao().getSessionById("ceremony-1", alice)?.secretShare, + "the first profile's share is untouched by the second's row appearing", + ) + assertEquals( + "ee".repeat(32), + db.dkgSessionDao().getSessionById("ceremony-1", bob)?.secretShare, + ) + assertTrue( + db.dkgSessionDao().getKeyHoldingSessions(alice).size == 1 && + db.dkgSessionDao().getKeyHoldingSessions(bob).size == 1, + "one ceremony, two rows, and each profile finds exactly its own", + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepositoryProfileScopeJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepositoryProfileScopeJvmTest.kt new file mode 100644 index 00000000..34c590bd --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepositoryProfileScopeJvmTest.kt @@ -0,0 +1,265 @@ +package press.mantra.compose.database.repository + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import java.nio.file.Files +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import okio.Path.Companion.toPath +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.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import kotlin.time.Instant + +/** + * The seam between the ceremony screens and the ritual rows. + * + * `DkgSession` is keyed by the ceremony *and* the profile, so none of these reads + * names a row without one. The profile is the repository's to supply rather than + * each screen's: a screen asking about "the ceremony" means the one it is showing, + * for the person in front of it, and cannot be made to mean anyone else. That is the + * same arrangement `DatabaseNostrRepository` has for the fetch queues and the other + * two repositories have for `canSign`. + * + * Which leaves one case with no answer at all -- nothing open -- and these pin what + * it does. A flow emits its empty value rather than the device's rows, and `approve` + * publishes nothing. Not a case the app reaches with a screen on top of it, but the + * flows are collected from view models that outlive an identity switch, and + * "whichever profile the database ordered first" is the one answer that must not + * come back. + */ +class DatabaseDkgRepositoryProfileScopeJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val scope = CoroutineScope(Dispatchers.IO) + + private val aliceKey = PrivateKey(ByteVector32("0a".repeat(32))) + private val bobKey = PrivateKey(ByteVector32("0b".repeat(32))) + private val alice: HexKey = aliceKey.nostrPublicKeyHex() + private val bob: HexKey = bobKey.nostrPublicKeyHex() + + private val roomId = "11".repeat(32) + + /** Nobody open, until a test says otherwise. */ + private val active = MutableStateFlow(null) + + private val repository by lazy { DatabaseDkgRepository(db, scope, activeIdentity = active) } + + /** + * One room, and a ceremony in it for each profile -- the shape that makes an + * unscoped read ambiguous rather than merely wrong. + */ + private suspend fun seed() { + listOf(alice, bob).forEach { seedProfile(it) } + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = alice, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ) + ) + seedCeremony("alices", alice) + seedCeremony("bobs", bob) + // One message in each profile's view, from the other member. + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = "alices", + userPublicKey = alice, + participantPublicKey = bob, + kind = 30450, + payload = "01".repeat(33), + ) + ) + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = "bobs", + userPublicKey = bob, + participantPublicKey = alice, + kind = 30450, + payload = "02".repeat(33), + ) + ) + } + + @Test + fun `nothing open reads no rituals`() = runBlocking { + seed() + + assertNull( + repository.observeSessionById("alices").first(), + "a ceremony id alone names no row, so there is nothing to emit", + ) + assertEquals(emptyList(), repository.observeSessionsForChatRoom(roomId).first()) + assertNull(repository.observeLatestSessionForChatRoom(roomId).first()) + assertEquals(emptyList(), repository.observeMessages("alices").first()) + assertNull(repository.getLatestSessionForChatRoom(roomId)) + } + + @Test + fun `the profile that is open reads its own, and only its own`() = runBlocking { + seed() + + active.value = identity(aliceKey) + + assertEquals("alices", repository.observeSessionById("alices").first()?.id) + assertNull( + repository.observeSessionById("bobs").first(), + "the other profile's ceremony is not this one's to open", + ) + assertEquals(listOf("alices"), repository.observeSessionsForChatRoom(roomId).first().map { it.id }) + assertEquals("alices", repository.observeLatestSessionForChatRoom(roomId).first()?.id) + assertEquals("alices", repository.getLatestSessionForChatRoom(roomId)?.id) + assertEquals( + listOf(bob), + repository.observeMessages("alices").first().map { it.participantPublicKey }, + ) + } + + /** Switching profile switches every answer, without a room or a ceremony changing. */ + @Test + fun `switching the open profile switches the answers`() = runBlocking { + seed() + + active.value = identity(aliceKey) + assertEquals(listOf("alices"), repository.observeSessionsForChatRoom(roomId).first().map { it.id }) + + active.value = identity(bobKey) + assertEquals(listOf("bobs"), repository.observeSessionsForChatRoom(roomId).first().map { it.id }) + assertEquals("bobs", repository.getLatestSessionForChatRoom(roomId)?.id) + assertEquals( + listOf(alice), + repository.observeMessages("bobs").first().map { it.participantPublicKey }, + "and Bob's view holds the message Bob received", + ) + } + + /** + * An approval is a publication: it puts this profile's host key in front of the + * group and enrols them in its permanent signing quorum. With nobody open there + * is no profile to publish as, and the repository stops rather than picking one. + */ + @Test + fun `nothing open approves nothing`() = runBlocking { + seed() + val room = assertNotNull(db.chatRoomDao().findChatRoomById(roomId)) + + repository.approve( + localChatRoom = room, + sessionId = "alices", + step = DkgApprovalStep.HOST_KEY, + nostrPrivateKey = aliceKey.value.toByteArray(), + ) + + assertNull( + db.dkgSessionDao().getSessionById("alices", alice)?.hostKeyApprovedAt, + "no profile was open, so nobody agreed to anything", + ) + assertNull(db.dkgSessionDao().getSessionById("bobs", bob)?.hostKeyApprovedAt) + assertEquals( + emptyList(), + db.giftWrapPayloadDao().getByChatRoomAndKinds(roomId, listOf(30450)), + "and nothing of anyone's went out", + ) + } + + /** + * The deliberate exception, pinned here as well as at the DAO: a ceremony's + * threshold key is its published result, the same on every participant's row, and + * the two flows that resolve the room it derives answer with no profile open. + * Scoping them would make a screen resolve nothing for a fact that does not + * depend on a profile -- see `DkgSessionDao.getCeremonyThresholdPublicKey`. + */ + @Test + fun `a ceremonys own key needs no profile open`() = runBlocking { + seed() + + // Null because no *key state* has been signed, not because nobody is open -- + // reaching that branch at all means the ceremony's key resolved. + assertNull(repository.observeSignedGroupKeyState("alices").first()) + + assertEquals( + "aa".repeat(32), + db.dkgSessionDao().getCeremonyThresholdPublicKey("alices"), + "the ceremony's key is readable with nobody open", + ) + } + + private suspend fun seedCeremony(id: String, owner: HexKey) = + db.dkgSessionDao().upsert( + DkgSession( + id = id, + chatRoomId = roomId, + coordinatorPublicKey = owner, + userPublicKey = owner, + threshold = 2, + participantCount = 3, + stage = DkgRitualStage.COLLECTING_HOST_KEYS, + hostPublicKey = owner, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + thresholdPublicKey = "aa".repeat(32), + createdAt = Instant.fromEpochSeconds(1_000), + ) + ) + + private suspend fun seedProfile(publicKey: HexKey) { + 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 = "profile", nostrEventId = nostrEventId) + ) + } + + private fun prefsFile(name: String) = + Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath() + + private fun identity(key: PrivateKey) = Identity.signing( + id = key.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = key, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }), + business = null, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TwoProfilesCompleteOneCeremonyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TwoProfilesCompleteOneCeremonyJvmTest.kt new file mode 100644 index 00000000..ef36fd26 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TwoProfilesCompleteOneCeremonyJvmTest.kt @@ -0,0 +1,346 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.DkgSession +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 + +/** + * A key ceremony run to the end, with two of its three members being two profiles on + * one device. + * + * This is the state everything else about the per-profile keying exists to protect, + * and the only way to see it is to finish a ceremony. A ChillDKG hashes the host keys + * and `n` into its own session identity, so a device contributing one host key where + * the group counts two does not get a weaker key — it gets no key. Sharing one + * `DkgSession` row between two profiles was exactly that: one host key, one piece of + * randomness, one share, for two members. + * + * Measured, by pointing `acceptProposal` back at a row of any profile's and watching: + * both tests here fail, and they fail *earlier* than expected. The second profile + * adopts the first's row, finds a host key already published on it, and so never + * publishes one of its own — leaving the ceremony two host keys short of the three it + * counts, for good. It does not produce a bad key, it stalls at the first round and + * stays there. "No row for bob" is the first assertion to go. + * + * So what is asserted is the outcome rather than the plumbing: three participants, + * three distinct shares, one threshold key they all agree on, and the two profiles on + * this device holding a different share each. + * + * ### What is real here and what is not + * + * The protocol is real — `ChillDkgRitualManager` drives genuine ChillDKG through the + * bundled secp256k1, and the shares at the end are shares. What is simulated is the + * relays: [pump] moves each device's queued payloads into the others rather than + * encrypting and sending them, and approvals are given as soon as a step asks for + * them. That is the right seam. `ChillDkgRitualOrderingTest` already covers the + * protocol's ordering rule directly, and the transports have their own tests; what is + * uncovered until here is the manager completing a ceremony at all, which nothing else + * in the suite does. + * + * Alice and Bob share one database, as two profiles on a device do. Carol gets her + * own, so the ceremony has a participant that is genuinely elsewhere and the room has + * to be derived to the same id on both sides rather than copied. + */ +class TwoProfilesCompleteOneCeremonyJvmTest { + + /** The device holding two profiles. */ + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + /** Somebody else's. */ + private val carolDb: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() { + db.close() + carolDb.close() + } + + private val aliceKeys = KeyPair() + private val bobKeys = KeyPair() + private val carolKeys = KeyPair() + + private val alice = aliceKeys.pubKey.toHexKey() + private val bob = bobKeys.pubKey.toHexKey() + private val carol = carolKeys.pubKey.toHexKey() + + private val quorum = 2 + + /** + * One participant, as the manager sees it: whose profile, in which database, with + * which room and which secret. + */ + private data class Member( + val name: String, + val publicKey: HexKey, + val privateKey: ByteArray, + val database: MantraDatabase, + val room: LocalChatRoom, + ) + + private lateinit var members: List + private lateinit var ceremonyId: String + + /** Payloads already handed to a profile, so the pump does not re-feed the backlog. */ + private val delivered = mutableSetOf>() + + private suspend fun seedProfile(database: MantraDatabase, publicKey: HexKey) { + val nostrEventId = publicKey.take(63) + "f" + database.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + database.profileDao().upsert( + Profile(publicKey = publicKey, userName = publicKey.take(6), nostrEventId = nostrEventId) + ) + } + + /** + * The group's room, stood up on both devices. + * + * Derived rather than copied: a NIP-17 room's id is an aggregate of its member + * keys, so each device building it from its own point of view has to land on the + * same id. If that stopped being true the ceremony would run in two rooms and + * nothing would reach anybody, which is worth having the test depend on. + */ + private suspend fun openGroup() { + listOf(alice, bob, carol).forEach { + seedProfile(db, it) + seedProfile(carolDb, it) + } + + val onThisDevice = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = alice, + participantPublicKeys = listOf(bob, carol), + subject = "Two personas, one key", + ) + ) + val onCarols = assertNotNull( + carolDb.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = carol, + participantPublicKeys = listOf(alice, bob), + subject = "Two personas, one key", + ) + ) + assertEquals( + onThisDevice.chatRoom.id, + onCarols.chatRoom.id, + "both devices have to derive the same room or the ceremony runs in two", + ) + + members = listOf( + Member("alice", alice, aliceKeys.privKey!!, db, onThisDevice), + Member("bob", bob, bobKeys.privKey!!, db, onThisDevice), + Member("carol", carol, carolKeys.privKey!!, carolDb, onCarols), + ) + } + + /** A queued payload as the rumor its sender wrote, which is what the manager takes. */ + private suspend fun outbox(database: MantraDatabase, roomId: String): List = + database.giftWrapPayloadDao() + .getByChatRoomAndKinds(roomId, DkgRitualEvents.ALL.toList()) + .map { + Event( + id = it.id, + pubKey = it.publicKey, + createdAt = it.createdAt.epochSeconds, + kind = it.kind, + tags = it.tags, + content = it.content, + sig = "", + ) + } + + /** + * One turn of the relays: every payload any device has queued is handed to every + * profile that has not seen it, and then each is asked whether it is waiting on its + * owner and told yes. + * + * Returns whether anything moved, so the caller can stop when the ceremony has + * either finished or stalled rather than looping a fixed number of times. + */ + private suspend fun pump(): Boolean { + var moved = false + + val queued = members + .distinctBy { it.database } + .flatMap { outbox(it.database, it.room.chatRoom.id) } + .distinctBy { it.id } + + members.forEach { member -> + queued.forEach { event -> + if (delivered.add(member.publicKey to event.id)) { + moved = true + ChillDkgRitualManager.processRitualPayload( + database = member.database, + localChatRoom = member.room, + innerEvent = event, + userPublicKey = member.publicKey, + nostrPrivateKey = member.privateKey, + ) + } + } + } + + members.forEach { member -> + val session = sessionOf(member) ?: return@forEach + val step = ChillDkgRitualManager.pendingApproval(member.database, session) ?: return@forEach + + moved = true + ChillDkgRitualManager.approve( + database = member.database, + localChatRoom = member.room, + sessionId = session.id, + userPublicKey = member.publicKey, + step = step, + nostrPrivateKey = member.privateKey, + ) + } + + return moved + } + + private suspend fun sessionOf(member: Member): DkgSession? = + member.database.dkgSessionDao().getSessionById(ceremonyId, member.publicKey) + + /** Runs the ceremony until nothing more moves, or [limit] turns have passed. */ + private suspend fun runToCompletion(limit: Int = 24) { + repeat(limit) { + if (!pump()) return + if (members.all { sessionOf(it)?.stage == DkgRitualStage.COMPLETE }) return + } + } + + @Test + fun `two profiles on one device each come out of one ceremony with their own share`(): Unit = + runBlocking { + openGroup() + + // Alice opens it, which is also her agreeing to be in it. + ceremonyId = ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = members[0].room, + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!!, + threshold = quorum, + ).id + + runToCompletion() + + val finished = members.associateWith { assertNotNull(sessionOf(it), "no row for ${it.name}") } + + finished.forEach { (member, session) -> + assertEquals( + DkgRitualStage.COMPLETE, + session.stage, + "${member.name} did not finish: ${session.stage}, ${session.failureReason}", + ) + assertNotNull(session.secretShare, "${member.name} finished without a share") + } + + // The whole point: one key, agreed by everybody. + val keys = finished.values.map { assertNotNull(it.thresholdPublicKey) }.distinct() + assertEquals( + 1, + keys.size, + "three participants, one threshold key -- got ${keys.size}", + ) + + // And three shares of it, not one shared three ways. + val shares = finished.values.map { it.secretShare } + assertEquals( + 3, + shares.distinct().size, + "each participant holds its own share, including the two profiles here", + ) + + val alices = finished.getValue(members[0]) + val bobs = finished.getValue(members[1]) + assertNotEquals( + alices.secretShare, + bobs.secretShare, + "the two profiles on this device are two members of the quorum, not one", + ) + assertNotEquals(alices.hostPublicKey, bobs.hostPublicKey) + assertEquals( + alices.thresholdPublicKey, + bobs.thresholdPublicKey, + "of the same group's key", + ) + } + + /** + * The key both profiles signed up to is one the group can actually find: the room + * it derives resolves to the same id for each of them, and each resolves it through + * its own share rather than the other's. + */ + @Test + fun `each profile resolves the group's key as its own`(): Unit = runBlocking { + openGroup() + + ceremonyId = ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = members[0].room, + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!!, + threshold = quorum, + ).id + + runToCompletion() + + val thresholdKey = assertNotNull( + sessionOf(members[0])?.thresholdPublicKey, + "the ceremony has to have produced a key for this to be about anything", + ) + val adminRoomId = SharedKeyDerivation.marmotGroupId(thresholdKey) + + listOf(alice, bob).forEach { profile -> + val resolved = assertNotNull( + FrostSigningManager.completedKey(db, adminRoomId, profile), + "$profile holds a share and must resolve the group's key", + ) + assertEquals(ceremonyId, resolved.id) + assertEquals( + profile, + resolved.userPublicKey, + "and must resolve it through its own row, not the other profile's", + ) + assertTrue(FrostSigningManager.canSign(db, adminRoomId, profile)) + } + + // Carol is a member of the group but not a profile on this device, so this + // device holds nothing of hers to resolve. + assertEquals( + null, + FrostSigningManager.completedKey(db, adminRoomId, carol), + "a member who is not a profile here has no row here", + ) + } +}