test(dkg): close the five gaps the per-profile keying left uncovered
Named in review of 3c8489ad and taken in order of how much they mattered.
**A ceremony finished, which nothing in the suite did.**
`TwoProfilesCompleteOneCeremonyJvmTest` runs a real three-party ChillDKG through
the manager -- Alice and Bob as two profiles in one database, Carol in her own,
the room derived to the same id on both sides rather than copied -- and asserts
the outcome the whole change exists to protect: three distinct shares, one
threshold key, and each profile resolving the group's key through its own row.
Pointing `acceptProposal` back at a row of any profile's fails both tests, and
earlier than argued: the second profile adopts the first's row, finds a host key
already on it, publishes none of its own, and the ceremony stalls two host keys
short of three for good. It does not make a bad key -- it never makes one.
**Room's migration wiring**, which a bare-connection test cannot reach.
`DkgSessionMigrationThroughRoomJvmTest` stands a v21 file up from the committed
`schemas/21.json` -- fifty-five tables is too many to restate, and the export is
what a v21 device actually holds -- then opens it through `getRoomDatabase` so
Room runs the migration and compares what it left against the entities. Its
limits are measured rather than assumed: a column dropped from the rebuilt table
fails all three tests, an index renamed fails nothing, so index names rest on the
string assertions in the sibling test and are documented as doing so.
**`DatabaseDkgRepository` with nothing open.** Five flows and `approve` had no
coverage of the branch where no profile is active -- tested for the signing
repository and not this one. `approve` publishing a host key for a profile nobody
chose is the one worth pinning.
**`getCeremonyThresholdPublicKey`**, the single read that deliberately names no
profile, now has the property it was added for: a profile holding no row of that
ceremony still gets the key, because it is the group's published result.
**Three DAO reads that no test referenced**: `getLatestSubgroupSessionFor`, whose
new profile predicate decides a refusal a user sees, plus `observeLatestSessionFor`
and `observeMessages`.
No production code changes. 1002 tests, 139 suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c8489ad66
commit
a638b1916d
4 files changed
+1039
No files matched your search
+142
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+286
@@ -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<String> = 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<MantraDatabase>(name = databaseFile.absolutePath))
|
||||
.also { database = it }
|
||||
|
||||
private fun <T> 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+265
@@ -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<MantraDatabase>()
|
||||
)
|
||||
|
||||
@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<Identity?>(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,
|
||||
)
|
||||
}
|
||||
+346
@@ -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<MantraDatabase>()
|
||||
)
|
||||
|
||||
/** Somebody else's. */
|
||||
private val carolDb: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@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<Member>
|
||||
private lateinit var ceremonyId: String
|
||||
|
||||
/** Payloads already handed to a profile, so the pump does not re-feed the backlog. */
|
||||
private val delivered = mutableSetOf<Pair<HexKey, String>>()
|
||||
|
||||
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<Event> =
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user