feat: announce which key a room signs with, instead of rederiving it

A signer holds a different secret share under every ceremony it took part
in, and signing with the wrong one produces a partial signature that
cannot aggregate. Nothing said which was which: FrostSigningManager
found a room's key by walking every ceremony this device holds a share
for and rederiving each one's room id until one matched.

That search can only find rooms derived at the one path the constant
names. SharedKeyDerivation.parsePath was written to lift that limit and
was never called, so a room derived anywhere else was invisible to
signing.

So the coordinator now says it. GroupKeyStateEvent (kind 30326) carries
the threshold public key, the ceremony that made it and the path the
room's id came from, posted into the room as its first application
message and filed as a GroupKeyState row. completedKey reads that row
first and follows it to the share.

Nothing secret travels. Every member of the room can read the event, so
a share on it would be each member holding everyone else's -- a 1-of-n
key wearing a t-of-n's clothes. The event names the ceremony; the share
stays in DkgSession.secretShare on the device that generated it.

The coordinator is untrusted, as everywhere else in the ceremony, so a
state is verified rather than believed: the room's id *is* the threshold
key derived at the path, and one that does not rederive its own room is
dropped. That is the same guarantee the rederivation gave, kept rather
than traded for a lookup. The old scan stays behind it for rooms that
predate the table.

Announced after the members are added, which is the only order that
works -- adding them commits a new epoch and MLS will not let a member
read what was encrypted before the one they joined at. A member invited
later still misses it and falls back to the scan, which is where every
member was before this existed.

Replacement is this app's job. These are rumors inside a Marmot group
event, so no relay applies the 3xxxx rule, and the DAO keeps the newest
announcement per room so a backfill cannot walk a room backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-05 23:36:57 +02:00
1 parent 0319f1613b
commit a909108300
16 files changed
+6288 -21

No files matched your search

@@ -20,6 +20,7 @@ import press.mantra.compose.database.dao.FrostSigningSessionDao
import press.mantra.compose.database.dao.GiftWrapMessageDao
import press.mantra.compose.database.dao.GiftWrapPayloadDao
import press.mantra.compose.database.dao.GiftWrapSealDao
import press.mantra.compose.database.dao.GroupKeyStateDao
import press.mantra.compose.database.dao.InReplyToRelationDao
import press.mantra.compose.database.dao.MantraArtifactDao
import press.mantra.compose.database.dao.MantraArtifactVersionDao
@@ -71,6 +72,7 @@ import press.mantra.compose.database.model.Connection
import press.mantra.compose.database.model.GiftWrapMessage
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GiftWrapSeal
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.InReplyToRelation
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
@@ -132,6 +134,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
GiftWrapMessage::class,
GiftWrapSeal::class,
GiftWrapPayload::class,
GroupKeyState::class,
InReplyToRelation::class,
MantraArtifact::class,
MantraArtifactVersion::class,
@@ -169,7 +172,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 6,
version = 7,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -194,7 +197,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// both shapes Room can migrate itself. A ceremony that completed before
// this reads back null, and signing falls back to not cross-checking
// shares rather than refusing to run.
AutoMigration(from = 5, to = 6)
AutoMigration(from = 5, to = 6),
// v7 adds the GroupKeyState table, which records what shared key a room
// signs with instead of leaving it to be rederived. A new table is a
// shape Room migrates itself. Rooms created before this have no row and
// fall back to the rederivation scan in FrostSigningManager.completedKey,
// which is why that scan stays.
AutoMigration(from = 6, to = 7)
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -216,6 +225,8 @@ abstract class MantraDatabase: RoomDatabase() {
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
abstract fun groupKeyStateDao(): GroupKeyStateDao
abstract fun connectionDao(): ConnectionDao
abstract fun giftWrapMessageDao(): GiftWrapMessageDao