From ed7a866421999eb351d376ab7c9c64b00e42a58b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 03:29:11 +0200 Subject: [PATCH] test: run a real signing session between two devices, over two databases a8f6638 changed three things that only exist *between* devices, and every test it came with checks one device at a time. A room's key state is now signed into being rather than announced; two devices have to resolve the room's derivation path independently and land on the same event id; and what they sign as is the room's own key. None of that is visible from a single database, so none of it was covered where it could actually break. This drives the whole protocol for real. Two MantraDatabase instances, a share each out of Frost.trustedDealerKeygen, and the wire held by hand: broadcast() queues a MarmotInnerEvent for the outbound pipeline, so ferrying those rows between databases is the group with MLS taken out -- and MLS has no opinion about any of the claims here. Nothing is stubbed; the FROST calls, the room's path resolution, the id rebuild on arrival, the aggregate and its verification all run. It lands in jvmTest rather than commonTest because it needs a database, and MantraDatabaseJvmTest already established that Room's in-memory builder and the host's bundled SQLite work on this target. jvmTest goes 223 -> 235; commonTest is unchanged at 217. The ferry keeps its already-delivered set on the *receiving* device rather than the sending one. A sender-side set looked equivalent and was not: a message goes to every other device, so the first delivery hid it from everybody else, and the three-member test failed because the member who never signed never got the signature. That is a bug in the fake wire rather than in the group, and it is the kind a two-device test would never have shown. ## What is pinned - propose() writes no state of its own. The creator has decided nothing until a quorum signs, which is the whole point of it no longer being an announcement. - The payload of the room's first message is a 30326 authored by the room's id -- not the group's root key, which is what an untweaked cache produces and what this used to be. - A second device with no path in its room metadata still rebuilds the same event id. It resolves the path itself and checks it against the room's id, so agreeing on the id is agreeing on every byte signed, the author included. - A member who has not approved publishes nothing. - Two devices that both applied the signature hold the same state, attributed to the room rather than to anybody in particular. - The finished signature verifies against the room's id, and passes GroupKeyStateEvent.isSignedByGroup, which is the check a receiver runs. - The third member of a 2-of-3 picks up the state without ever being asked to sign, because completing needs nothing of theirs. - A dialect signed in the room is authored by the room, and its stored signature verifies against the room's id. That is the half of a8f6638 that touches artifacts and dialects, end to end for the first time. - A proposer cannot choose the key the group signs as: a proposal carrying a true key state re-authored under the root key opens no session at all, because the receiver rebuilds the id under its own reading of the room. - A true key state nobody signed no longer becomes a row. This is the behaviour change worth being able to point at. - A room derived at m/9420/0/1 signs at m/9420/0/1, so nothing has quietly hardcoded MARMOT_ADMIN_GROUP_PATH. - A room not derived from the key at all still signs as the threshold key, with a null derivationPath -- the fallback kept for rooms the app no longer makes. ## They were checked against mutations, not just run Tests that pass are not evidence until something makes them fail. Four deliberate regressions were introduced and reverted: - unsignedEventOf deriving at the empty path instead of the room's: 7 failed. - signingPath returning its first candidate without checking it derives the room: 1 failed, the not-derived room. - propose writing the state row locally, the way announce() did: 4 failed. - isSignedByGroup accepting an author that is not the room: 7 failed, 3 of them in the existing GroupKeyStateTest. Co-Authored-By: Claude Opus 5 --- .../managers/SignedGroupKeyStateTest.kt | 645 ++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt new file mode 100644 index 00000000..b9a822ad --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -0,0 +1,645 @@ +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.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.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.FrostSigningSession +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.database.model.types.FrostSigningStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * Two devices, two databases, one real signing session, and the wire between + * them held by hand. + * + * Everything else about signing is checked in pieces: `FrostSigningRoundTest` + * runs the FROST calls, `GroupKeyStateTest` judges a finished state, and + * `SignedArtifactTest` turns a signature into rows. None of them can see the + * claims that only exist *between* devices, and those are exactly what this + * change is made of: + * + * - a room's key state is not written by whoever creates the room, it is + * signed into existence by a quorum and lands on every device at once; + * - both devices resolve the room's derivation path independently and arrive + * at the same event id, without either being told what it is; + * - what they sign as is the room's own key, so the author on the finished + * event is the id of the room it was signed in. + * + * The transport is the only thing faked. `FrostSigningManager.broadcast` queues + * a `MarmotInnerEvent` for the outbound pipeline to encrypt, so ferrying those + * rows between two databases *is* the group, minus MLS -- which has no opinion + * about any of the above. + */ +class SignedGroupKeyStateTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony; the test is about what happens after one. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + + /** The group's root key as nostr would name it -- what nothing should be signed as. */ + private val rootPublicKey = XonlyPublicKey(keyMaterial.thresholdPublicKey).value.toHex() + + private val ceremonyId = "ceremony".padEnd(64, '0') + + /** The admin room: the group's key walked to the admin path, which is its id. */ + private val adminRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val adminRoomDescription = SharedKeyDerivation.describe("Admins of the group.") + + /** + * The members, in the order the ceremony placed them. + * + * `FrostSigningManager.signerIds` derives a member's FROST id from the + * bytewise sort of the ceremony's host keys, so these are chosen to sort the + * same way as the list reads -- member 0 holds `secretShares[0]`, and so on. + */ + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val devices = mutableListOf() + + @AfterTest + fun closeDatabases() = devices.forEach { it.db.close() } + + /** + * One member's device: their own database, their own share, their own view + * of the room. Nothing is shared between two of these but what is ferried. + */ + private inner class Device( + val publicKey: HexKey, + val db: MantraDatabase, + val room: LocalChatRoom + ) { + /** + * Inner events this device has already been handed, so a pump terminates. + * + * Kept on the receiver rather than the sender because a message goes to + * every other device: one sender-side set would let the first delivery + * hide the message from everybody else, which is a bug in the wire and + * not in the group. + */ + val received = mutableSetOf() + + val roomId: String get() = room.chatRoom.id + + suspend fun session(sessionId: String): FrostSigningSession? = + db.frostSigningSessionDao().getSessionById(sessionId) + + suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) + } + + private suspend fun device( + publicKey: HexKey, + signerIndex: Int, + roomId: String = adminRoomId, + description: String? = adminRoomDescription, + ceremonyRoomId: String = roomId + ): Device { + val db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + // Profile hangs off a nostr event, and a room off a profile. Neither is + // anything this test is about; they are the foreign keys in the way. + val nostrEventId = "e$publicKey".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128) + ) + ) + db.profileDao().upsert(Profile(publicKey = publicKey, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = publicKey, + subject = "#admins", + description = description, + mlsGroupState = null + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = ceremonyRoomId, + coordinatorPublicKey = members.first(), + userPublicKey = publicKey, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[signerIndex], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[signerIndex].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() } + ) + ) + + // The host keys every device holds, which is what places a signer in the + // ceremony's order. Derived rather than stored, so both devices have to + // agree on this or their partial signatures land in the wrong slots. + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index] + ) + ) + } + + return Device(publicKey, db, LocalChatRoom(chatRoom = chatRoom)) + .also { devices += it } + } + + private suspend fun ceremonyOn(device: Device): DkgSession = + device.db.dkgSessionDao().getSessionById(ceremonyId)!! + + /** Every protocol row this device has queued, oldest first, protocol order within a second. */ + private suspend fun outbox(device: Device) = device.db.marmotInnerEventDao() + .getByChatRoomAndKinds( + chatRoomId = device.roomId, + kinds = (FrostSigningEvents.ALL + GroupKeyStateEvent.KIND).toList() + ) + .filter { it.publicKey == device.publicKey } + // Two rows queued in the same second tie on createdAt. Kind breaks it in + // the order a session runs, which is what a relay would have preserved. + .sortedWith(compareBy({ it.createdAt }, { it.kind })) + + /** + * Hands everything one device has queued to the other, until neither has + * anything left. + * + * The payload is stored before it is dispatched, which is not incidental: + * that is what the real inbound path does, and it is what lets a message + * arriving before the proposal it belongs to be replayed afterwards rather + * than lost. + */ + private suspend fun pump(vararg between: Device) { + var moved = true + while (moved) { + moved = false + + for (from in between) { + for (to in between) { + if (from === to) continue + + outbox(from).forEach { queued -> + if (!to.received.add(queued.id)) return@forEach + moved = true + + to.db.marmotInnerEventDao().upsert(queued) + FrostSigningManager.processSigningPayload( + database = to.db, + localChatRoom = to.room, + innerEvent = Event( + id = queued.id, + pubKey = queued.publicKey, + createdAt = queued.createdAt.epochSeconds, + kind = queued.kind, + tags = queued.tags, + content = queued.content, + sig = "" + ), + userPublicKey = to.publicKey + ) + } + } + } + } + } + + // ---- The key state, from proposal to row ------------------------------ + + @Test + fun `creating a room proposes its key state rather than announcing it`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + + // The whole change: the creator has decided nothing. Until a quorum + // signs, the room has no key state at all -- not even on the device that + // asked for one. + assertNull(creator.keyState(), "propose must write no state of its own") + + val queued = outbox(creator) + val proposal = queued.firstOrNull { it.kind == FrostSigningEvents.PROPOSAL } + assertNotNull(proposal, "the room's first message must be a signing proposal") + + val payload = Event.fromJson(proposal.content) + assertEquals(GroupKeyStateEvent.KIND, payload.kind) + assertEquals(thresholdPublicKey, payload.content) + assertEquals(session.eventId, payload.id) + } + + @Test + fun `the key state a room proposes is authored by the room`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + + val payload = Event.fromJson(session.unsignedEventJson) + + // Signing runs at the room's derivation path, so the key the group signs + // as is the room's own id. Not the group's root key, which is what an + // untweaked cache would produce and what this used to be. + assertEquals(adminRoomId, payload.pubKey) + assertTrue(payload.pubKey != rootPublicKey) + assertEquals(SharedKeyDerivation.formatPath(), session.derivationPath) + } + + @Test + fun `a second device reaches the same event without being told the path`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + // No description on this one, so its path cannot come from the room's + // metadata and has to be resolved and checked against the room's id. + val other = device(members[1], signerIndex = 1, description = null) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + pump(creator, other) + + val received = other.session(session.id) + assertNotNull(received, "the proposal should have opened a session on the other device") + + // Rebuilt from the event's own fields under this device's own reading of + // the room. Agreeing on the id is agreeing on every byte that is signed, + // the author included. + assertEquals(session.eventId, received.eventId) + assertEquals(session.derivationPath, received.derivationPath) + } + + @Test + fun `nothing of a member's own goes out before they approve`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + pump(creator, other) + + assertTrue(FrostSigningManager.isAwaitingApproval(other.session(session.id)!!)) + assertTrue( + outbox(other).isEmpty(), + "a device that has not been asked yet must publish nothing" + ) + assertNull(other.keyState()) + } + + @Test + fun `a quorum signing it puts the same state on every device`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + pump(creator, other) + + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + assertEquals(FrostSigningStage.COMPLETE, creator.session(session.id)?.stage) + assertEquals(FrostSigningStage.COMPLETE, other.session(session.id)?.stage) + + // The payoff. Two devices, neither of which was sent a state, both + // holding the same one because both applied the signature themselves. + listOf(creator, other).forEach { device -> + val state = device.keyState() + assertNotNull(state, "every signer should hold the state the group signed") + assertEquals(thresholdPublicKey, state.thresholdPublicKey) + assertEquals(ceremonyId, state.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath) + // Signed by the room, so attributed to the room -- nobody in + // particular said this. + assertEquals(adminRoomId, state.announcedBy) + assertTrue(state.verifies()) + } + } + + @Test + fun `the signature on the finished state verifies against the room's id`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + val signed = FrostSigningManager.signedEvent(creator.session(session.id)!!) + assertNotNull(signed, "a completed session must carry a signed event") + + assertEquals(adminRoomId, signed.pubKey) + assertTrue( + Nip01Crypto.verify( + signature = signed.sig.hexToByteArray(), + hash = signed.id.hexToByteArray(), + pubKey = adminRoomId.hexToByteArray() + ), + "the room's key state must be signed by the room's own key" + ) + + // And it is checkable on its own terms, by the function a receiver uses. + assertTrue( + GroupKeyStateEvent.isSignedByGroup( + event = signed, + thresholdPublicKey = thresholdPublicKey, + path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ) + ) + } + + @Test + fun `the third member picks up the state without having signed it`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val absent = device(members[2], signerIndex = 2) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator) + ) + pump(creator, other, absent) + + // Only two of the three approve, which is the point of a 2-of-3 key. + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other, absent) + + assertNull( + absent.session(session.id)?.signApprovedAt, + "the third member must not have been made to sign" + ) + assertNotNull( + absent.keyState(), + "a member the quorum did not need still learns what the room signs with" + ) + assertEquals(adminRoomId, absent.keyState()?.announcedBy) + } + + // ---- What a room signs as, once it has a key state --------------------- + + @Test + fun `a dialect the group signs is authored by the room it was signed in`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val template = DialectEvent.build(name = "Sepedi", country = "ZA", language = "nso") + val session = FrostSigningManager.proposeSigning( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + kind = template.kind, + tags = template.tags, + content = template.content + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val dialect = device.db.mantraDialectDao().getDialectById(session.eventId) + assertNotNull(dialect, "a signed dialect should exist on every signer's device") + assertEquals("Sepedi", dialect.name) + // The ask this whole change serves: the group's work is authored by + // the group's room, not by the member who typed it and not by the + // bare threshold key. + assertEquals(adminRoomId, dialect.publicKey) + assertTrue(dialect.publicKey != creator.publicKey) + assertTrue(dialect.publicKey != rootPublicKey) + assertTrue( + Nip01Crypto.verify( + signature = dialect.signature.hexToByteArray(), + hash = dialect.id.hexToByteArray(), + pubKey = adminRoomId.hexToByteArray() + ) + ) + } + } + + @Test + fun `a proposer cannot choose the key the group signs as`() = runBlocking { + val other = device(members[1], signerIndex = 1) + + // The one thing choosing the path would buy an attacker: a proposal for + // a perfectly true key state, re-authored under the group's root key + // rather than the room's. Every field a signer is shown is honest; the + // author is not the one this room answers to. + val createdAt = 1_700_000_000L + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = adminRoomId, + dkgSessionId = ceremonyId, + path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ) + val forged = Event( + id = EventHasher.hashId( + pubKey = rootPublicKey, + createdAt = createdAt, + kind = GroupKeyStateEvent.KIND, + tags = tags, + content = thresholdPublicKey + ), + pubKey = rootPublicKey, + createdAt = createdAt, + kind = GroupKeyStateEvent.KIND, + tags = tags, + content = thresholdPublicKey, + sig = "" + ) + + val sessionId = "5".repeat(64) + FrostSigningManager.processSigningPayload( + database = other.db, + localChatRoom = other.room, + innerEvent = Event( + id = "6".repeat(64), + pubKey = members[0], + createdAt = createdAt, + kind = FrostSigningEvents.PROPOSAL, + tags = FrostSigningEvents.assembleTags( + sessionId = sessionId, + dkgSessionId = ceremonyId + ), + content = forged.toJson(), + sig = "" + ), + userPublicKey = other.publicKey + ) + + // The author is rebuilt from this device's own reading of the room, so + // the id does not come out where the proposal says it should and no + // session opens at all. Nothing of this member's is published, and there + // is nothing to approve. + assertNull( + other.session(sessionId), + "a proposal authored under a key the room did not derive must not open a session" + ) + } + + @Test + fun `a true announcement nobody signed no longer becomes a state`() = runBlocking { + val member = device(members[0], signerIndex = 0) + + // Exactly what a member used to be able to say on their own, and exactly + // what this room signs with: true, well formed, addressed correctly, and + // agreed to by nobody. That is now the whole reason it is refused. + val announcement = Event( + id = "7".repeat(64), + pubKey = member.publicKey, + createdAt = 1_700_000_000L, + kind = GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = adminRoomId, + dkgSessionId = ceremonyId, + path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ), + content = thresholdPublicKey, + sig = "" + ) + + assertNull(GroupKeyStateManager.record(member.db, adminRoomId, announcement)) + assertNull(member.keyState(), "an unsigned announcement must leave no row behind") + } + + @Test + fun `a room derived off the default path signs at its own`() = runBlocking { + // The reason a path is resolved and checked rather than assumed. This + // room is real, derived, and nowhere near the constant -- a session that + // hardcoded MARMOT_ADMIN_GROUP_PATH would sign it as the wrong key and + // every signature would verify against nothing. + val sibling = listOf(9420L, 0L, 1L) + val siblingRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling) + + val creator = device( + publicKey = members[0], + signerIndex = 0, + roomId = siblingRoomId, + description = SharedKeyDerivation.describe("A second room.", sibling) + ) + val other = device( + publicKey = members[1], + signerIndex = 1, + roomId = siblingRoomId, + description = SharedKeyDerivation.describe("A second room.", sibling) + ) + + val session = GroupKeyStateManager.propose( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + key = ceremonyOn(creator), + path = sibling + ) + + assertEquals(SharedKeyDerivation.formatPath(sibling), session.derivationPath) + assertEquals(siblingRoomId, Event.fromJson(session.unsignedEventJson).pubKey) + + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + val state = creator.keyState() + assertNotNull(state, "a room off the default path must still reach a signed state") + assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath) + assertEquals(siblingRoomId, state.announcedBy) + assertTrue(state.verifies()) + } + + @Test + fun `a room that is not derived from the key signs as the key itself`() = runBlocking { + // The fallback kept for rooms the app no longer makes: the ceremony ran + // in this very room, so the room's id is a random 32 bytes rather than + // anything walked to. There is no room key to sign as, so it signs as the + // group's -- which is what it did before any of this. + val undeerived = "d".repeat(64) + val creator = device( + publicKey = members[0], + signerIndex = 0, + roomId = undeerived, + description = null + ) + + val template = DialectEvent.build(name = "Sepedi", country = "ZA", language = "nso") + val session = FrostSigningManager.proposeSigning( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + kind = template.kind, + tags = template.tags, + content = template.content + ) + + assertNull(session.derivationPath, "no path reaches a room that was not derived") + assertEquals(emptyList(), session.pathIndices()) + assertEquals(rootPublicKey, Event.fromJson(session.unsignedEventJson).pubKey) + } +}