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 Ngako
2026-09-05 23:36:34 +02:00
parent 0319f1613b
commit a909108300
16 changed files with 6288 additions and 21 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -0,0 +1,56 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.GroupKeyState
@Dao
abstract class GroupKeyStateDao {
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract suspend fun getByChatRoomId(chatRoomId: String): GroupKeyState?
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract fun observeByChatRoomId(chatRoomId: String): Flow<GroupKeyState?>
/** Every room that signs with one ceremony's key. One, today. */
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List<GroupKeyState>
@Upsert
abstract suspend fun upsert(groupKeyState: GroupKeyState)
/**
* Files a state, keeping the newest announcement per room.
*
* This is where the event's replaceable semantics actually happen. Relays
* never see a `GroupKeyStateEvent` -- it is a rumor inside a Marmot group
* event -- so nothing upstream applies the 3xxxx replacement rule, and an
* announcement that arrives twice would otherwise be two rows racing for
* one primary key.
*
* Older announcements are dropped rather than applied, so a redelivery from
* a relay backfill cannot walk the room back to a state it has already
* moved past. An announcement at the same instant is kept as a no-op: two
* members announcing the same true thing agree by construction, since both
* derived it from the room they are standing in.
*
* Returns the state now on file.
*/
@Transaction
open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState {
val known = getByChatRoomId(groupKeyState.chatRoomId)
if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known
val stamped = groupKeyState.copy(
createdAt = known?.createdAt ?: groupKeyState.createdAt,
updatedAt = groupKeyState.createdAt
)
upsert(stamped)
return stamped
}
}

View File

@@ -32,7 +32,9 @@ import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.ChillDkgRitualManager
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.managers.FrostSigningManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.MlsGroupCache
import press.mantra.compose.managers.MarmotInboundManager
import co.touchlab.kermit.Logger
@@ -469,16 +471,29 @@ abstract class NostrDao(
// itself. The manager is idempotent, so a redelivered
// message re-runs a step it has already taken.
if (groupEventResult is GroupEventResult.ApplicationMessage) {
Event.fromJsonOrNull(groupEventResult.innerEventJson)
?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) }
?.let { innerEvent ->
FrostSigningManager.processSigningPayload(
database = database,
localChatRoom = localChatRoom,
innerEvent = innerEvent,
userPublicKey = activeKeyPair.pubKey.toHex()
)
Event.fromJsonOrNull(groupEventResult.innerEventJson)?.let { innerEvent ->
when {
FrostSigningEvents.isFrostSigningKind(innerEvent.kind) ->
FrostSigningManager.processSigningPayload(
database = database,
localChatRoom = localChatRoom,
innerEvent = innerEvent,
userPublicKey = activeKeyPair.pubKey.toHex()
)
// What key this room signs with. Filed
// rather than acted on, and only after
// the room rederives from the key it
// names -- the manager drops anything
// that does not, whoever sent it.
GroupKeyStateEvent.isGroupKeyStateKind(innerEvent.kind) ->
GroupKeyStateManager.record(
database = database,
chatRoomId = localChatRoom.chatRoom.id,
innerEvent = innerEvent
)
}
}
}
}
} else {

View File

@@ -18,6 +18,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
@@ -705,6 +706,13 @@ data class ChatMessage(
// account of the same thing.
in FrostSigningEvents.ALL -> null
// The room saying what key it signs with. Standing state rather
// than something that happened, and the room's own id already
// says it to anyone who can derive -- so there is nothing here a
// reader of the transcript needs told. Falling through to
// "unsupported" would put the raw announcement in the chat.
GroupKeyStateEvent.KIND -> null
else -> {
ChatMessage(
giftWrapPayloadId = null,

View File

@@ -0,0 +1,106 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.managers.SharedKeyDerivation
/**
* Which shared key a room signs with, as this device has been told.
*
* Written from a `GroupKeyStateEvent` -- the room's own announcement of the
* ceremony behind it -- and read when a signing request arrives, to pick the
* secret share out of the right [DkgSession]. A device that took part in more
* than one ceremony holds more than one share, and they are not
* interchangeable: a partial signature made with the wrong one cannot
* aggregate.
*
* ### Why this is a row and not a rederivation
*
* `FrostSigningManager.completedKey` found the key by walking every ceremony
* this device holds a share for and rederiving each one's room id until one
* matched. That works, and it stays as the fallback for rooms made before this
* table existed, but it can only find rooms derived at the *default* path --
* the one path the constant names. A room derived anywhere else was invisible
* to it. [derivationPath] is what fixes that, which is also the reason the path
* is stored rather than assumed.
*
* ### Nothing secret lives here
*
* [thresholdPublicKey] is the key signatures verify against, not the secret
* behind it, and [dkgSessionId] is a pointer. The share itself never leaves
* `DkgSession.secretShare` on the device that generated it.
*
* One row per room: a room is derived from one key, and a group that re-runs
* its ceremony derives a different room rather than re-keying this one.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("dkgSessionId"),
],
)
data class GroupKeyState(
/** The Marmot room this is the key state for. Its id is the derived key. */
@PrimaryKey
val chatRoomId: String,
/**
* The ceremony that made the key, and so the row holding this device's
* share of it.
*
* Not a foreign key on purpose. A member can be in the room without
* holding a share -- they were added after the ceremony, or reinstalled --
* and the state is still worth keeping: it says what the room signs with,
* which is what tells them they cannot.
*/
val dkgSessionId: String,
/** The group's ChillDKG threshold public key, 33-byte compressed hex. */
val thresholdPublicKey: HexKey,
/** The path [chatRoomId] was derived at, `m/9420/0/0` style. */
val derivationPath: String,
/** Who announced it. Kept for the transcript; the derivation is what vouches for it. */
val announcedBy: HexKey,
/** The announcement's own timestamp, so the newest state per room wins. */
val announcedAt: Instant,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
): TimestampedEntity, LocalStoreEntity {
/** [derivationPath] as indices, or null if it is not a walkable path. */
fun pathIndices(): List<Long>? = SharedKeyDerivation.parsePathString(derivationPath)
/**
* Whether this state actually describes the room it claims to.
*
* The room's id is the threshold key derived at the path, so this is the
* whole of the trust model: a state that does not rederive its own room was
* announced by somebody pointing the room at a key it was not made from.
* Checked before the row is written and cheap enough to check again.
*/
fun verifies(): Boolean {
val path = pathIndices() ?: return false
return runCatching {
SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId
}.getOrDefault(false)
}
}

View File

@@ -3,9 +3,11 @@ package press.mantra.compose.database.repository
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.repository.DkgRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -48,6 +50,31 @@ class DatabaseDkgRepository(
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
ChillDkgRitualManager.pendingApproval(database, session)
override suspend fun announceGroupKeyState(
chatRoomId: String,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState? {
val thresholdPublicKey = session.thresholdPublicKey ?: return null
return try {
GroupKeyStateManager.announce(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
dkgSessionId = session.id,
thresholdPublicKey = thresholdPublicKey
)
} catch (e: Throwable) {
// The room not deriving from the key it is about to announce is a
// bug rather than a condition, but it is not worth failing the room
// over: the group still has a working chat, and signing simply falls
// back to the rederivation scan it used before there was a state.
logger.e("Error announcing the key state for $chatRoomId", e)
null
}
}
override suspend fun approve(
localChatRoom: LocalChatRoom,
sessionId: String,

View File

@@ -26,6 +26,7 @@ import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgRitualStage
@@ -789,16 +790,32 @@ object FrostSigningManager {
* to be outside of -- while a group event needs an MLS one, so the two
* cannot be the same room.
*
* They are still bound together, and by construction rather than by a
* column: the #admins room's id *is* the key, derived from it by
* [SharedKeyDerivation.marmotGroupId]. Rederiving is what finds the key
* here, which means a room cannot be pointed at a key it was not derived
* from.
* They are bound together by the room's [GroupKeyState]: the announcement
* the room opened with, naming the ceremony behind it. That is a lookup
* rather than a search, and it carries the derivation path, so a room
* derived anywhere other than [SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH]
* is findable at all -- which the rederivation below cannot manage, since it
* can only rederive at the one path the constant names.
*
* Falls back to a ceremony held in this very room, which is not how the app
* wires things today but costs one lookup to keep honest.
* The state buys none of its authority from being written down. It is only
* ever stored having rederived the room it describes, so what actually binds
* a room to a key is still that the room's id *is* the key, and a room still
* cannot be pointed at a key it was not derived from.
*
* Two fallbacks behind it, both for rooms that predate the table: the
* original scan over every ceremony this device holds a share for, and then
* a ceremony held in this very room, which is not how the app wires things
* today but costs one lookup to keep honest.
*/
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? {
GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state ->
database.dkgSessionDao().getSessionById(state.dkgSessionId)?.takeIf { key ->
key.stage == DkgRitualStage.COMPLETE &&
key.secretShare != null &&
key.thresholdPublicKey == state.thresholdPublicKey
}?.let { return it }
}
database.dkgSessionDao().getKeyHoldingSessions().firstOrNull { session ->
session.stage == DkgRitualStage.COMPLETE &&
session.thresholdPublicKey?.let {

View File

@@ -0,0 +1,177 @@
package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* Announces and files what key a room signs with.
*
* The coordinator [announce]s once, as the new room's first message; every
* other member [record]s what arrives. Both ends land on the same
* [GroupKeyState] row, which is what a signing request is resolved against --
* see `FrostSigningManager.completedKey`.
*
* Nothing here is trusted on the strength of who said it. A state is kept only
* if the room's id rederives from the key it names, which is the same check
* `completedKey` used to make by scanning, and the reason a coordinator cannot
* point a room at a key it was not made from.
*/
object GroupKeyStateManager {
private const val TAG = "GroupKeyStateManager"
private val logger = Logger.withTag(TAG)
/** The key state a room signs under, or null while it has none. */
suspend fun keyStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
database.groupKeyStateDao().getByChatRoomId(chatRoomId)
/**
* Says what the freshly made room signs with, and files it locally.
*
* Called once, by the member who created the room, before anybody has been
* added to it -- the announcement is the room's first message, so a member
* arriving on a welcome finds it waiting rather than having to be told
* separately.
*
* Queued before it is recorded, matching the signing pipeline: a crash
* between the two costs a duplicate announcement, which [record] folds
* away, rather than a room whose key nobody ever named.
*
* Refuses to announce a state that does not describe the room, because a
* state that fails [GroupKeyState.verifies] here is this device having
* derived the room from one key and announced another -- a bug worth
* failing on rather than broadcasting.
*/
suspend fun announce(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
dkgSessionId: String,
thresholdPublicKey: HexKey,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
createdAt: Long = Clock.System.now().epochSeconds
): GroupKeyState {
val state = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = userPublicKey,
announcedAt = Instant.fromEpochSeconds(createdAt)
)
check(state.verifies()) {
"Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}"
}
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
path = path
)
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
// The rumor id the outbound pipeline will recompute from these
// same fields when it assembles the event to encrypt.
id = EventHasher.hashId(
pubKey = userPublicKey,
createdAt = createdAt,
tags = tags,
content = thresholdPublicKey,
kind = GroupKeyStateEvent.KIND
),
publicKey = userPublicKey,
kind = GroupKeyStateEvent.KIND,
createdAt = Instant.fromEpochSeconds(createdAt),
tags = tags,
content = thresholdPublicKey,
chatRoomId = chatRoomId
)
)
logger.i("Announcing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}")
return database.groupKeyStateDao().replace(state)
}
/**
* Files an inbound announcement, or drops it and says why.
*
* Storing is all this adds to [stateFrom], which is where the deciding
* happens -- kept apart so the check a member's safety rests on can be
* exercised without standing up a database.
*/
suspend fun record(
database: MantraDatabase,
chatRoomId: String,
innerEvent: Event
): GroupKeyState? =
stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) }
/**
* The state an announcement amounts to, or null if it amounts to none.
*
* Every reason to return null is a reason the announcement does not describe
* this room, and none of them are about who sent it: a member with no share,
* or none of the ceremony at all, can announce a true state and it is still
* true. What cannot be tolerated is a state naming a key the room was not
* derived from, because acting on one means signing with a share that will
* not aggregate -- or, worse, treating a key the group does not hold as the
* key the group holds.
*/
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags)
if (announced != null && announced != chatRoomId) {
logger.w("Key state for room $announced arrived in $chatRoomId; dropping")
return null
}
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
if (thresholdPublicKey == null) {
logger.w("Key state in $chatRoomId carries no threshold key; dropping")
return null
}
val dkgSessionId = GroupKeyStateEvent.parseDkgSessionId(innerEvent.tags)
if (dkgSessionId == null) {
logger.w("Key state in $chatRoomId names no ceremony; dropping")
return null
}
val path = GroupKeyStateEvent.parsePath(innerEvent.tags)
if (path == null) {
logger.w("Key state in $chatRoomId carries no walkable derivation path; dropping")
return null
}
val state = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = innerEvent.pubKey,
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt)
)
// The whole trust model, in one line. Anybody may say what this room
// signs with; only the truth rederives the room they said it in.
if (!state.verifies()) {
logger.w(
"Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " +
"${state.derivationPath}, which does not derive room $chatRoomId; dropping"
)
return null
}
return state
}
}

View File

@@ -115,12 +115,40 @@ object SharedKeyDerivation {
?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) }
?: return null
val path = line.trimStart().removePrefix(PATH_MARKER).trim()
return parsePathString(line.trimStart().removePrefix(PATH_MARKER).trim())
}
/** The indices [tweakScalar] can actually tell apart: a BIP32-shaped uint32. */
private val INDEX_RANGE = 0L..0xFFFFFFFFL
/**
* A bare `m/9420/0/0` as indices, or null if it is not one.
*
* Split out from [parsePath] because a path also travels on its own, in a
* `GroupKeyStateEvent`'s [press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag],
* where there is no description to dig it out of. Both spellings have to
* agree on what a path is, so there is only one reader of one.
*
* Indices outside a uint32 are rejected, which matters because a path now
* arrives from the wire rather than only from [MARMOT_ADMIN_GROUP_PATH].
* [tweakScalar] serialises an index as its low four bytes, so without this
* `m/4294967296/0/0` walks to the same key as `m/0/0/0` and a room could be
* described by a path nobody would write. Nothing is stolen by that -- a
* state still has to derive the room it names -- but it would make
* [formatPath] a lossy round trip and leave two spellings of one path for
* any later code to disagree over. Negative indices go the same way: they
* are not a thing a path has.
*/
fun parsePathString(path: String): List<Long>? {
if (!path.startsWith("m/")) return null
return path.removePrefix("m/")
.split("/")
.map { segment -> segment.toLongOrNull() ?: return null }
.map { segment ->
val index = segment.toLongOrNull() ?: return null
if (index !in INDEX_RANGE) return null
index
}
.takeIf { it.isNotEmpty() }
}

View File

@@ -24,6 +24,11 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
* anyone --[ 30325 failure ]-> everyone abandon + blame
* ```
*
* [GroupKeyStateEvent] sits just past them on 30326. It is not part of a
* session -- it is the standing fact a session is opened against, saying which
* key the room signs with -- so it is deliberately outside [ALL], which is what
* the inbound path dispatches a session message on.
*
* ### Why 3032x
*
* These share the inner-event space with the nip30303 document kinds, which run

View File

@@ -0,0 +1,108 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
/**
* What key a Marmot room signs with, announced into the room itself.
*
* The coordinator posts one as the room's first message, right after creating
* it. Content is the group's ChillDKG threshold public key; the tags name the
* ceremony that produced it and the path the room's id was derived at.
*
* ```
* coordinator --[ 30326 group key state ]-> everyone "this room signs with K, at m/9420/0/0"
* ```
*
* ### What it is for
*
* 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. This is the record that says which. A member reads the state,
* follows [FrostKeyTag] to the `DkgSession` row their own device already holds,
* and takes the share from there -- the association travels, the share does not.
*
* Nothing secret is in here, and that is not an accident. Every member of the
* room can read it, so a share put on this event would be every member holding
* every other member's share, which is a 1-of-n key wearing a t-of-n's clothes.
*
* ### Trusted no further than it can be checked
*
* The coordinator posts it, and the coordinator is untrusted by construction.
* A receiver therefore verifies rather than believes: the room's id *is* the
* threshold key derived at the path, so
* `SharedKeyDerivation.marmotGroupId(content, path) == chatRoomId` has to hold
* or the state is dropped. That is the same check
* `FrostSigningManager.completedKey` made by rederiving, kept rather than
* replaced -- this event makes the association explicit and cheap to look up,
* not easier to forge.
*
* ### Replaceable, by this app rather than by a relay
*
* Like every kind in [FrostSigningEvents] this is a rumor inside a Marmot group
* event, so no relay ever sees it and the addressable semantics of the 3xxxx
* range never fire. [DTag] is the room id and the newest state per room wins,
* which the local store enforces on its own. Being able to say it twice is what
* matters in practice: a redelivered announcement, or a second member saying the
* same true thing, folds away instead of accumulating.
*
* One room only ever names one key today. A group that re-runs its ceremony
* derives a *different* room from the new key, so rotation in place does not
* arise -- and if it ever does, the verification above is what has to change
* first, because a rotated key no longer derives the room it is announced in.
*/
object GroupKeyStateEvent {
/**
* Sits with the signing family in the Marmot inner-event space. 30320-30325
* are a signing session; this is the standing fact a session is opened
* against, so it is adjacent rather than inside.
*/
val KIND: Kind = 30326
fun isGroupKeyStateKind(kind: Kind): Boolean = kind == KIND
/**
* The tags for a state naming [dkgSessionId], for the room derived at [path].
*
* The room id goes on as the `d` tag so the event is self-addressing: a
* reader can tell which room a state belongs to without the envelope it
* arrived in, which is what makes dropping a state announced into the wrong
* room a check rather than an assumption.
*/
fun assembleTags(
chatRoomId: String,
dkgSessionId: String,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
): Array<Array<String>> = arrayOf(
DTag.assemble(chatRoomId),
FrostKeyTag.assemble(dkgSessionId),
FrostDerivationPathTag.assemble(path)
)
/** The room this state is about, or null if it names none. */
fun parseChatRoomId(tags: Array<Array<String>>): String? =
tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null }
/** The ceremony whose share signs for this room, or null if it names none. */
fun parseDkgSessionId(tags: Array<Array<String>>): String? =
tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId
/** The derivation path, or null if it carries none or an unwalkable one. */
fun parsePath(tags: Array<Array<String>>): List<Long>? =
tags.firstNotNullOfOrNull(FrostDerivationPathTag::parse)?.path
/**
* The threshold public key a state announces, or null when the content is
* not one.
*
* Shape only -- 33 compressed bytes of hex. Whether it is *the* key for the
* room is settled by rederiving the room id from it, not by looking at it.
*/
fun parseThresholdPublicKey(content: String): HexKey? =
content.trim()
.takeIf { it.length == 66 && it.all { char -> char.isDigit() || char in 'a'..'f' || char in 'A'..'F' } }
}

View File

@@ -0,0 +1,42 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import press.mantra.compose.managers.SharedKeyDerivation
/**
* The path the room's id was derived at, `m/9420/0/0` style.
*
* Recorded rather than assumed. `SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH`
* is the only path anything walks today, but a room derived at a second one
* would be unfindable by a lookup that hardcodes the first, and the path is
* also what rebuilds the `TweakCache` a signing session needs.
*
* Hardened indices are rejected on parse: hardened derivation needs the parent
* private key, which in a threshold group nobody has, so a path carrying one
* was never walked.
*/
class FrostDerivationPathTag(
val path: List<Long>,
) {
fun toTagArray() = assemble(path = path)
companion object {
const val TAG_NAME = "frost_path"
fun parse(tag: Array<String>): FrostDerivationPathTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val path = SharedKeyDerivation.parsePathString(tag[1]) ?: return null
return FrostDerivationPathTag(path = path)
}
fun assemble(path: List<Long>): Array<String> =
arrayOf(TAG_NAME, SharedKeyDerivation.formatPath(path))
fun assemble(frostDerivationPathTag: FrostDerivationPathTag) =
assemble(path = frostDerivationPathTag.path)
}
}

View File

@@ -2,6 +2,7 @@ package press.mantra.compose.repository
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -42,6 +43,19 @@ interface DkgRepository {
nostrPrivateKey: ByteArray
)
/**
* Tells [chatRoomId] which ceremony's key it signs with, as its first message.
*
* Called once by whoever creates the room. Null if the ritual has produced
* no key yet, or if the room does not derive from the one it produced --
* both of which mean there is nothing true to announce.
*/
suspend fun announceGroupKeyState(
chatRoomId: String,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState?
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
@@ -65,6 +79,12 @@ interface DkgRepository {
step: DkgApprovalStep,
nostrPrivateKey: ByteArray
) = Unit
override suspend fun announceGroupKeyState(
chatRoomId: String,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState? = null
}
}
}

View File

@@ -251,7 +251,8 @@ class DkgRitualViewModel(
if (isActionPending.value) return
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return
val session = loaded.session ?: return
val thresholdPublicKey = session.thresholdPublicKey ?: return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
@@ -383,6 +384,28 @@ class DkgRitualViewModel(
logger.e("Failed to add members to admin group $groupId", it)
}.getOrElse { addable.map { (publicKey, _) -> publicKey } }
// The room's first message: which ceremony's key it signs with, and
// the path its id was derived at. What a signer reaches for when a
// signing request arrives and it has to pick one of its shares.
//
// After the members are added rather than before, 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 epoch they joined
// at. Announced first, the announcement would reach nobody but its
// author. It is still the room's first *application* message -- what
// comes before it is handshake.
//
// A member invited later still misses it for the same reason, and is
// left where every member was before this event existed: falling back
// to FrostSigningManager.completedKey's rederivation. Re-announcing
// on invite is the fix, and is cheap because a repeat announcement
// folds away rather than accumulating.
dkgRepository.announceGroupKeyState(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = activeUserPublicKey,
session = session
)
isActionPending.value = false
if (notAdded.isNotEmpty()) {

View File

@@ -0,0 +1,245 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* What a room's key-state announcement is allowed to convince a member of.
*
* The announcement is made by the coordinator, and the coordinator is untrusted
* by construction -- the same assumption every other part of the ceremony is
* written under. So the interesting cases here are all the ones where a state
* is *wrong*: a member who acts on a state naming a key their room was not made
* from signs with a share that cannot aggregate, or worse, treats a key the
* group does not hold as the key the group holds.
*
* `GroupKeyStateManager` needs a database and so cannot be stood up here. What
* can be is the check it defers to, which is where the whole trust model lives.
*/
class GroupKeyStateTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
/** A second group's key, for the states that name the wrong one. */
private val otherKey = PrivateKey(
Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2")
).publicKey().value.toHex()
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
private fun state(
chatRoomId: String = this.chatRoomId,
thresholdPublicKey: String = this.thresholdPublicKey,
derivationPath: String = SharedKeyDerivation.formatPath(path)
) = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
thresholdPublicKey = thresholdPublicKey,
derivationPath = derivationPath,
announcedBy = "c00rd1na70r",
announcedAt = Instant.fromEpochSeconds(1_700_000_000)
)
@Test
fun `a state describing the room it was announced in verifies`() {
assertTrue(state().verifies())
}
@Test
fun `a state naming another group's key does not verify`() {
// The attack this is here for: a coordinator pointing the room at a key
// the group never made, so that everything signed in it is signed by
// whoever holds that key instead.
assertFalse(state(thresholdPublicKey = otherKey).verifies())
}
@Test
fun `a state naming the right key at the wrong path does not verify`() {
// The path is half the derivation, so getting it wrong reaches a
// different room just as surely as getting the key wrong does.
assertFalse(state(derivationPath = "m/9420/0/1").verifies())
}
@Test
fun `a state for one room does not verify against another`() {
assertFalse(state(chatRoomId = otherKey).verifies())
}
@Test
fun `a state carrying an unwalkable path does not verify`() {
// Hardened derivation needs the parent private key, which nobody in a
// threshold group has, so a hardened path was never walked to anything.
assertFalse(state(derivationPath = "m/9420'/0/0").verifies())
assertFalse(state(derivationPath = "9420/0/0").verifies())
assertFalse(state(derivationPath = "").verifies())
}
@Test
fun `the tags a state is announced on read back as they were written`() {
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path
)
assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags))
assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags))
assertEquals(path, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a threshold key is only read out of content that is one`() {
assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey))
// 32 bytes is an x-only key, not the 33-byte compressed point a ceremony
// reports; anything else is not a key at all.
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey(""))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key"))
assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66)))
}
@Test
fun `a path survives the trip through a tag`() {
val deep = listOf(9420L, 7L, 0L, 1L)
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep)
assertEquals(deep, GroupKeyStateEvent.parsePath(tags))
}
@Test
fun `a room derived at a path other than the default still verifies at it`() {
// The reason the path is announced rather than assumed: a lookup that
// hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all.
val sibling = listOf(9420L, 0L, 1L)
val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling)
assertTrue(
state(
chatRoomId = siblingRoom,
derivationPath = SharedKeyDerivation.formatPath(sibling)
).verifies()
)
}
// ---- The announcement as it actually arrives -------------------------
//
// Everything above checks the verdict on a state already assembled. These
// check the assembling: a real Event, with the tags and content an
// announcement is carried on, through the function the inbound path calls.
private fun announcement(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
pubKey: String = "c00rd1na70r",
createdAt: Long = 1_700_000_000
) = Event(
id = "an-id",
pubKey = pubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
)
@Test
fun `an announcement of the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement())
assertEquals(chatRoomId, state?.chatRoomId)
assertEquals("ceremony-1", state?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.derivationPath)
// Attribution and ordering come off the event, not off the clock.
assertEquals("c00rd1na70r", state?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
}
@Test
fun `an announcement naming another group's key is dropped`() {
// The one that matters: a coordinator pointing the room at a key the
// group never made. Everything else here is malformed input; this is
// well-formed input that lies.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey)))
}
@Test
fun `an announcement addressed to another room is dropped`() {
val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere)))
}
@Test
fun `an announcement missing any of what it has to say is dropped`() {
val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
// No ceremony to reach a share through.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray())
)
)
// No path, so nothing to rebuild a TweakCache from.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
)
)
// No key.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = "")))
}
@Test
fun `an announcement carrying no d tag is judged on its derivation alone`() {
// The d tag is a convenience for a reader holding the event on its own.
// Dropping it loses nothing that matters, because the room it arrived in
// plus the derivation still settle the question.
val undirected = arrayOf(
arrayOf("frost_key", "ceremony-1"),
arrayOf("frost_path", "m/9420/0/0")
)
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId
)
}
@Test
fun `a path index wider than a uint32 is not a path`() {
// tweakScalar serialises an index as its low four bytes, so m/4294967296
// would otherwise walk exactly where m/0 does -- one room, two spellings,
// both verifying. Rejected at the parser so formatPath stays a round trip.
assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0"))
assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0"))
assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295"))
assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0"))
}
@Test
fun `a state whose path indices are out of range does not verify`() {
// Reachable only by constructing the row directly; the parser above
// refuses to build one. Checked because verifies() is what everything
// else defers to, and it should not be the thing that trusts its input.
assertFalse(state(derivationPath = "m/4294967296/0/0").verifies())
}
}