feat: sign a room's key state into being, at the room's own key

Two changes that turned out to be one. A room's key state stops being something
its creator announces and becomes something the group signs, and every FROST
signature moves from the group's root threshold key to the key derived at the
room's own path -- which is the room's id. The second is what makes the first
worth having: a key state is now signed by the very key it names.

Supersedes the announcement introduced in a909108, and changes the author of
every event the group signs, including the artifacts of 786c060.

## The key state is proposed, not announced

a909108 had the room's creator write the GroupKeyState row, say so in the room
on kind 30326, and every receiver keep it if the room's id rederived from the
key it named. That check was sound and is still here -- a state that does not
rederive its own room is dropped, whoever sent it -- but it left the first thing
a group ever does as the one thing a single member decides alone.

So the key state goes through the door everything else the group says goes
through. GroupKeyStateManager.announce becomes propose, which opens a
FrostSigningEvents.PROPOSAL over an unsigned 30326 and writes no row. The state
comes into existence when a quorum has signed it, on every device at once,
applied by FrostSigningManager.complete like any other signed proposal:

    creator  --[ 30320 proposal over an unsigned 30326 ]-> everyone
    ...members approve, nonces, signer set, partials, aggregate...
    everyone --applies the signed 30326 locally-->  GroupKeyState row

Nothing waits on it. Between creating a room and that session completing there
is no state to read, and completedKey's rederivation scan -- kept from before
the table existed -- is what keeps the room signable in the meantime, including
for the key-state session's own members. That is the only reason a bootstrap
here does not deadlock, and the scan's doc now says so rather than describing
itself as legacy.

proposeSigning gains an optional `key`, for the one caller that cannot be asked
which key the room signs with because establishing that is its whole job. It is
honoured only if this device actually holds a share of it, so naming a ceremony
cannot talk a session into signing with material it has not got.

## Everything signs as the room, not as the group's root key

unsignedEventOf and advance now build from SharedKeyDerivation.derive at the
room's path instead of TweakCache.create on the bare threshold key. Both halves
had to move together: a signature aggregates against whatever the cache carries,
so the cache and the author on the event have to be the same derivation or
nothing verifies.

Since marmotGroupId(K, path) *is* derive(K, path).hex, the pubkey on every event
a room signs -- dialect, artifact, chapter, key state -- is now that room's id.
A reader checking one needs no lookup at all: the key they expect is the id of
the room they found it in. marmotGroupId's doc now carries that second meaning,
and there is deliberately no second name for the value; "the room's id" and "the
key it signs as" are one function because they are one key.

The path is resolved by FrostSigningManager.signingPath and never taken from a
proposal, because it decides which key the group signs as -- a proposer able to
choose it could have every signer put their share behind an author of the
proposer's choosing. Three candidates in descending order of knowledge (the
room's GroupKeyState, the path in its MIP-01 description, the app's default),
and one is accepted only if walking it reaches the room's id, which makes the
resolution self-checking rather than trusting. acceptProposal runs the same
resolution independently on every device.

Null is a real answer, not a failure: completedKey will still find a key for a
room that was never derived from it -- a ceremony held in that very room, the
fallback kept for rooms the app no longer makes -- and such a room has no key of
its own to sign as, so it signs as the threshold key, which is what it always
did.

## What a receiver now checks

GroupKeyStateManager.stateFrom asks two independent questions, and a state has
to answer both:

  - Is it true? The room's id is the key derived at the path, so a state that
    does not rederive its own room names a key the room was not made from.
    Unchanged, and still the half that safety rests on. It knows nothing about
    who is speaking, and that is deliberate: a member with no share can state a
    true state and it is still true.

  - Did the group say it? GroupKeyStateEvent.isSignedByGroup: the author must be
    the key the content walks to at the path in the tags, the id must hash the
    fields sitting next to it, and the signature must verify. Since that walk is
    the room's id, a passing state is signed by the room it is about.

The second does not make a state truer -- the derivation already settled truth.
It makes the record of what a room signs with a thing a quorum agreed to. The
practical effect is that a true state nobody signed is now refused, which is the
behaviour change worth knowing about: an unsigned 30326 from an older client is
stored as an inner event and dropped as a state.

## Restart safety, and a nullable column

FrostSigningSession gains derivationPath, and the database goes to v9 on an
auto-migration. It is an input and is stored for the same reason nonceRandom is:
the cache is rebuilt on every pass of advance, and a session that resolved a
different path after a restart would regenerate a different nonce from the same
seed -- publishing a partial signature against an aggregate nobody else
computed.

Nullable, meaning no derivation at all: the untweaked threshold key. That is
both the honest answer for a room not derived from the key and what sessions
predating the column read back as, so a session caught mid-flight by the
migration finishes under the key it began under rather than switching between
two of its own rounds.

SharedKeyDerivation.derive now takes its key back out of the cache rather than
from the point, so an empty path is a real answer equal to what a session
created from that cache signs against. No behaviour changes for a non-empty
path, where the walk overwrites it on the first step.

## One place that files a key state

ChatMessage.applyInnerEvent records it, which it must: the signed 30326 reaches
every device through applySignedEvent, and the branch there previously returned
null and dropped it. The now-duplicate dispatch in NostrDao is removed, so the
locally applied signature and any wire-borne 30326 take the identical path.
Still no chat line -- standing state, and the session already wrote the
transcript of it happening.

## Elsewhere

DkgRepository.announceGroupKeyState becomes proposeGroupKeyState, taking the
room and returning the signing session rather than the state, since the state is
not what the call produces any more. DkgRitualViewModel calls it after members
are added, unchanged and for the unchanged reason: adding them commits a new
epoch, and a proposal published before it reaches nobody who could sign it.

FrostSigningScreen describes a key-state proposal as the group's shared key with
its path and ceremony, rather than "Event of kind 30326" -- a member deciding
whether to sign should be shown the thing.

## Tests: 217, 0 failures

  - GroupKeyStateTest is rewritten around real quorum signatures from
    Frost.trustedDealerKeygen. New: a true state nobody signed is dropped, a
    member's own signature over one is dropped, one group signing about another
    group's key is dropped, a state edited after signing is dropped, a state
    signed at the wrong path is dropped, and the room signs as its own id.
  - SignedArtifactTest pins that an artifact's author is the room it was signed
    in, and explicitly not the group's root key.
  - FrostSigningRoundTest runs both rounds against the tweaked cache now, which
    is the part most likely to be silently miswired -- a badly built cache
    produces a signature that simply fails to verify, on every device, quietly.
  - SharedKeyDerivationTest pins the migration contract: a walk of no steps
    lands on the threshold key, and a null derivationPath reads back as that
    empty walk rather than as the default path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 03:18:58 +02:00
parent 9c50b4fcee
commit a8f6638325
22 changed files with 6342 additions and 288 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -172,7 +172,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 8,
version = 9,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -208,7 +208,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// ChatMessage. Nullable additions need no default and drop no data, so Room
// generates this one. Rows written before it come back null, which reads as
// "not a direct message" -- the only answer that is true of all of them.
AutoMigration(from = 7, to = 8)
AutoMigration(from = 7, to = 8),
// v9 adds the nullable FrostSigningSession.derivationPath, which says
// what key off the ceremony's the session signs as. Sessions started
// before it read back null, meaning the untweaked threshold key -- which
// is what they were signing as, so one caught mid-flight finishes the way
// it began rather than switching keys between two of its own rounds.
AutoMigration(from = 8, to = 9)
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -23,19 +23,19 @@ abstract class GroupKeyStateDao {
abstract suspend fun upsert(groupKeyState: GroupKeyState)
/**
* Files a state, keeping the newest announcement per room.
* Files a state, keeping the newest one 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.
* never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is
* applied from a completed signing session -- so nothing upstream applies
* the 3xxxx replacement rule, and a state that lands 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.
* Older states are dropped rather than applied, so a session replayed after
* a crash cannot walk the room back to a state it has already moved past. A
* state at the same instant is kept as a no-op: two runs at the same true
* state agree by construction, since both derived it from the room they are
* standing in.
*
* Returns the state now on file.
*/

View File

@@ -33,9 +33,7 @@ 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 press.mantra.compose.managers.MarmotReindexSweep
@@ -1240,30 +1238,23 @@ abstract class NostrDao(
// into, and because it writes its transcript lines
// itself. The manager is idempotent, so a redelivered
// message re-runs a step it has already taken.
//
// A GroupKeyStateEvent is deliberately not dispatched
// here. It is no longer something a member says --
// it is what a session like this one produces -- and
// `ChatMessage.applyInnerEvent` files it alongside
// every other event the group has signed.
if (groupEventResult is GroupEventResult.ApplicationMessage) {
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
)
Event.fromJsonOrNull(groupEventResult.innerEventJson)
?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) }
?.let { innerEvent ->
FrostSigningManager.processSigningPayload(
database = database,
localChatRoom = localChatRoom,
innerEvent = innerEvent,
userPublicKey = activeKeyPair.pubKey.toHex()
)
}
}
}
}
} else {

View File

@@ -10,6 +10,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.traits.UserViewableEntity
import press.mantra.compose.exceptions.MarmotUnprocessableInnerEventException
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.extensions.toHex
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
@@ -1007,12 +1008,29 @@ 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
// The group saying what key its room signs with, arriving here
// the way every group-signed event does: applied by
// `FrostSigningManager` once a quorum has signed the proposal it
// was carried in.
//
// Recorded rather than believed. `GroupKeyStateManager` keeps it
// only if the room rederives from the key it names *and* the
// group holding that key signed it, so a state reaching this
// line still has to earn the row.
//
// No chat line: this is standing state rather than something
// that happened, and the session that produced it has already
// written the transcript of it happening. Falling through to
// "unsupported" would put the raw event in the chat underneath
// that.
GroupKeyStateEvent.KIND -> {
GroupKeyStateManager.record(
database = database,
chatRoomId = groupId,
innerEvent = event
)
null
}
else -> {
ChatMessage(

View File

@@ -10,6 +10,7 @@ import kotlin.time.Instant
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.managers.SharedKeyDerivation
/**
* One FROST signing session, as this device sees it.
@@ -76,6 +77,24 @@ data class FrostSigningSession(
/** This device's FROST id: its index in the ceremony's participant order. */
val signerId: Int,
/**
* The path off the ceremony's key this session signs at, `m/9420/0/0` style,
* or null to sign as the untweaked threshold key.
*
* An input, and stored for the same reason every other input is: the
* [TweakCache] a signature aggregates against is rebuilt from it on every
* pass, and a session that resolved a different path after a restart would
* regenerate a different nonce from the same seed -- publishing a partial
* signature against an aggregate nobody else computed.
*
* Resolved from the room rather than from the proposer, and only accepted
* when it derives the room's own id, so what a session signs as is the room
* it is running in. Null is the honest answer for a room that is not derived
* from the key at all, and is what sessions predating this column read back
* as -- both of which signed as the threshold key itself.
*/
val derivationPath: String? = null,
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
/**
@@ -136,4 +155,15 @@ data class FrostSigningSession(
/** Whether this device was picked to sign. A t-of-n key does not need everyone. */
fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false
/**
* [derivationPath] as indices, and an empty list when there is none.
*
* Empty rather than null because that is what it means to
* `SharedKeyDerivation.derive`: a walk of no steps, ending on the threshold
* key itself. An unwalkable string reads the same way, since a path nobody
* can walk is not one this session ever signed at.
*/
fun pathIndices(): List<Long> =
derivationPath?.let { SharedKeyDerivation.parsePathString(it) } ?: emptyList()
}

View File

@@ -12,14 +12,19 @@ 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.
* Which shared key a room signs with, as the group itself has said.
*
* 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.
* Written from a `GroupKeyStateEvent` -- the room's own statement of the
* ceremony behind it, signed by the group's threshold key -- 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.
*
* The row appears on every member's device at once, when the signing session
* the room opened with produces a signature. Until then a room has no state and
* signing resolves its key by rederiving, which is what every room did before
* this table existed.
*
* ### Why this is a row and not a rederivation
*
@@ -75,10 +80,14 @@ data class GroupKeyState(
/** 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. */
/**
* Who said it: the group's own nostr identity, since only a quorum can put
* a state on the record. Kept for the transcript -- the derivation below and
* the signature on the event are what vouch for it.
*/
val announcedBy: HexKey,
/** The announcement's own timestamp, so the newest state per room wins. */
/** The signed event's own timestamp, so the newest state per room wins. */
val announcedAt: Instant,
override val createdAt: Instant = Clock.System.now(),
@@ -91,10 +100,13 @@ data class GroupKeyState(
/**
* 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.
* The room's id is the threshold key derived at the path, so a state that
* does not rederive its own room points the room at a key it was not made
* from. Checked before the row is written and cheap enough to check again.
*
* Half the trust model, and the half that does not care who is speaking. The
* other half is `GroupKeyStateEvent.isSignedByGroup`, which asks whether the
* group said it rather than whether it is true.
*/
fun verifies(): Boolean {
val path = pathIndices() ?: return false

View File

@@ -3,7 +3,7 @@ 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.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.managers.ChillDkgRitualManager
@@ -50,29 +50,25 @@ class DatabaseDkgRepository(
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
ChillDkgRitualManager.pendingApproval(database, session)
override suspend fun announceGroupKeyState(
chatRoomId: String,
override suspend fun proposeGroupKeyState(
localChatRoom: LocalChatRoom,
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
}
): FrostSigningSession? = try {
GroupKeyStateManager.propose(
database = database,
localChatRoom = localChatRoom,
userPublicKey = userPublicKey,
key = session
)
} catch (e: Throwable) {
// The room not deriving from the key it is about to propose is a bug
// rather than a condition, and a ceremony with no key yet is a caller
// that got ahead of itself. Neither is 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 proposing the key state for ${localChatRoom.chatRoom.id}", e)
null
}
override suspend fun approve(

View File

@@ -11,12 +11,10 @@ import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.crypto.frost.AggregatedNonce
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import fr.acinq.bitcoin.crypto.frost.TweakCache
import fr.acinq.bitcoin.utils.Either
import kotlin.time.Clock
import kotlin.time.Instant
@@ -54,8 +52,20 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
* encrypted group event reaches everyone, rather than one sealed wrap per
* member per message.
*
* Three things are genuinely different, and each of them is why this is a
* separate manager rather than another branch of that one.
* ### What it signs as
*
* The room's own key, not the group's root threshold key. A signing session is
* created with the [SharedKeyDerivation] cache for the path the room was derived
* at, so the `pubkey` on every event a room signs is that room's id -- see
* [SharedKeyDerivation.marmotGroupId], where those are one value. A reader
* holding a signed dialect therefore needs no lookup to check it: the author
* they expect is the id of the room they found it in.
*
* The path comes from the room, never from a proposal -- [signingPath] -- because
* it decides which key the group signs as.
*
* Three things are genuinely different from a ceremony, and each of them is why
* this is a separate manager rather than another branch of that one.
*
* ### It does not need everybody
*
@@ -89,9 +99,20 @@ object FrostSigningManager {
/**
* Opens a signing session, making this device the coordinator.
*
* [unsignedEvent] is signed as it stands apart from its `pubkey`, which is
* The event is signed as it stands apart from its `pubkey`, which is
* replaced with the group's key: a signature over an event claiming
* somebody else's author would verify against nothing.
*
* [key] names the ceremony to sign under, for the one caller that knows it
* before the room does: `GroupKeyStateManager.propose`, whose whole job is to
* establish the answer [completedKey] would otherwise look up -- a room
* proposing its own key state cannot be asked which key it signs with.
* Everybody else leaves it null and is resolved from the room, which is the
* only form that cannot name a ceremony the room has nothing to do with.
*
* Even then it is taken only if this device actually holds a share of it, so
* naming one cannot talk the session into signing with material it has not
* got; anything else falls back as though none was given.
*/
suspend fun proposeSigning(
database: MantraDatabase,
@@ -100,15 +121,18 @@ object FrostSigningManager {
kind: Kind,
tags: Array<Array<String>>,
content: String,
key: DkgSession? = null,
createdAt: Long = Clock.System.now().epochSeconds
): FrostSigningSession {
val key = completedKey(database, localChatRoom.chatRoom.id)
val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
?: completedKey(database, localChatRoom.chatRoom.id)
?: throw IllegalStateException("This group has no shared key to sign with")
val signerId = signerIdOf(database, key, userPublicKey)
?: throw IllegalStateException("This device is not a participant in ceremony ${key.id}")
val signerId = signerIdOf(database, ceremony, userPublicKey)
?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}")
val unsignedEvent = unsignedEventOf(key, kind, tags, content, createdAt)
val path = signingPath(database, localChatRoom, ceremony)
val unsignedEvent = unsignedEventOf(ceremony, path, kind, tags, content, createdAt)
val sessionId = RandomInstance.bytes(32).toHex()
val session = FrostSigningSession(
@@ -116,10 +140,11 @@ object FrostSigningManager {
chatRoomId = localChatRoom.chatRoom.id,
coordinatorPublicKey = userPublicKey,
userPublicKey = userPublicKey,
dkgSessionId = key.id,
threshold = key.threshold,
participantCount = key.participantCount,
dkgSessionId = ceremony.id,
threshold = ceremony.threshold,
participantCount = ceremony.participantCount,
signerId = signerId,
derivationPath = path?.let(SharedKeyDerivation::formatPath),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
// Fresh per session, and never reused: this is the seed the device's
@@ -131,7 +156,7 @@ object FrostSigningManager {
database.frostSigningSessionDao().upsert(session)
announceStarted(database, session)
logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${key.id}")
logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}")
broadcast(
database = database,
@@ -253,8 +278,15 @@ object FrostSigningManager {
// 32 bytes every signer puts their share behind, so taking the proposer's
// word for it would let them have the group sign one thing while being
// shown another.
//
// The author it is rebuilt under comes from this device's own reading of
// the room, for the same reason. A proposer who could choose the path
// could choose the key the group signs as, and every signer would put
// their share behind an author none of them checked.
val path = signingPath(database, localChatRoom, key)
val unsignedEvent = unsignedEventOf(
key = key,
path = path,
kind = proposed.kind,
tags = proposed.tags,
content = proposed.content,
@@ -280,6 +312,7 @@ object FrostSigningManager {
threshold = key.threshold,
participantCount = key.participantCount,
signerId = signerId,
derivationPath = path?.let(SharedKeyDerivation::formatPath),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = RandomInstance.bytes(32).toHex()
@@ -460,8 +493,7 @@ object FrostSigningManager {
val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return
val secretShare = key.secretShare?.let { PrivateKey(ByteVector32(it)) } ?: return
val thresholdPublicKey = key.thresholdPublicKey?.let { PublicKey(ByteVector(it.hexToByteArray())) }
?: return
val thresholdPublicKey = key.thresholdPublicKey ?: return
try {
// A signature the group has already made settles this session whether
@@ -486,7 +518,14 @@ object FrostSigningManager {
return
}
val tweakCache = TweakCache.create(thresholdPublicKey)
// The room's key rather than the group's root key. A signature
// aggregates against whatever the cache carries, so the cache and the
// author on the event have to be the same derivation -- and the
// session stores the path exactly so that every pass rebuilds the
// same one.
val tweakCache = SharedKeyDerivation
.derive(thresholdPublicKey, session.pathIndices())
.cache
val message = ByteVector(session.eventId.hexToByteArray())
val publicShares = key.publicShareList()
@@ -815,10 +854,10 @@ object FrostSigningManager {
* to be outside of -- while a group event needs an MLS one, so the two
* cannot be the same room.
*
* 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]
* They are bound together by the room's [GroupKeyState]: the statement the
* room opened with and the group signed, 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.
*
@@ -827,10 +866,17 @@ object FrostSigningManager {
* 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.
* Two fallbacks behind it. The first is the original scan over every
* ceremony this device holds a share for, rederiving each one's room id
* until one matches; the second is a ceremony held in this very room, which
* is not how the app wires things today but costs one lookup to keep honest.
*
* They are not only for rooms that predate the table. A room's key state is
* itself signed by the group, so between creating a room and that session
* completing there is no state to read, and the scan is what keeps the room
* usable in the meantime -- including for the key-state session's own
* members. `GroupKeyStateManager.propose` is the one caller that does not
* come through here at all, because it names the ceremony outright.
*/
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? {
GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state ->
@@ -858,21 +904,72 @@ object FrostSigningManager {
completedKey(database, chatRoomId) != null
/**
* The unsigned event a session signs: the caller's fields under the group's
* key, with the id computed from them.
* The path off [key] that a signature made in this room is derived at, or
* null when the room is not derived from [key] at all.
*
* A room's id *is* its key, so a candidate path is right exactly when
* walking it reaches the room -- which makes this self-checking rather than
* trusting. Three candidates, in descending order of how much they know:
* the room's [GroupKeyState] if the group has signed one, the path recorded
* in the room's own MIP-01 description, and the default the app derives at.
* Whichever answers, it answers the same thing: two paths reaching one key
* is not a thing that happens.
*
* Nothing here comes off a proposal. The path decides what key the group
* signs as, so a proposer able to choose it could have every signer put
* their share behind an author of the proposer's choosing.
*
* Null is not a failure. `completedKey` will find a key for a room that was
* never derived from it -- a ceremony held in that very room, which is the
* fallback kept for rooms the app no longer makes -- and such a room has no
* key of its own to sign as, so it signs as the group's threshold key, which
* is what it always did.
*/
private suspend fun signingPath(
database: MantraDatabase,
localChatRoom: LocalChatRoom,
key: DkgSession
): List<Long>? {
val thresholdPublicKey = key.thresholdPublicKey ?: return null
val chatRoomId = localChatRoom.chatRoom.id
val candidates = listOfNotNull(
GroupKeyStateManager.keyStateFor(database, chatRoomId)?.pathIndices(),
SharedKeyDerivation.parsePath(localChatRoom.chatRoom.description),
SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
return candidates.firstOrNull { path ->
runCatching {
SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId
}.getOrDefault(false)
}
}
/**
* The unsigned event a session signs: the caller's fields under the room's
* own key, with the id computed from them.
*
* [path] is the room's, so the author is the room's id -- see
* [SharedKeyDerivation.marmotGroupId], where those are one value. A null
* path means no derivation at all, which is the group's threshold key
* itself; that is what a room not derived from the key signs as, because
* there is no room key for it to be.
*/
private fun unsignedEventOf(
key: DkgSession,
path: List<Long>?,
kind: Kind,
tags: Array<Array<String>>,
content: String,
createdAt: Long
): Event {
// Nostr identifies an author by the x-only key a BIP-340 signature
// verifies against, which for a FROST key is the tweaked threshold key
// rather than the 33-byte value the ceremony reports.
val thresholdPublicKey = PublicKey(ByteVector(key.thresholdPublicKey!!.hexToByteArray()))
val groupPubKey = TweakCache.create(thresholdPublicKey).tweakedPublicKey.value.toHex()
// verifies against, which for a FROST key is the tweaked key the signing
// cache carries -- not the 33-byte value the ceremony reports.
val groupPubKey = SharedKeyDerivation
.derive(key.thresholdPublicKey!!, path ?: emptyList())
.hex
return Event(
id = EventHasher.hashId(

View File

@@ -3,26 +3,41 @@ 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.DkgSession
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.nostr.frost.GroupKeyStateEvent
/**
* Announces and files what key a room signs with.
* Puts what key a room signs with to the group, and files what the group says.
*
* 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`.
* The room's creator [propose]s it as the new room's first message; every
* device [record]s the state once the signing session behind it produces a
* signature. 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.
* ### Why this is proposed rather than announced
*
* It used to be announced: the coordinator wrote the row, said so in the room,
* and every receiver kept the statement if the room's id rederived from the key
* it named. That check is still here and still the thing safety rests on -- a
* state that does not rederive its own room is dropped, whoever it came from --
* but it left the *first* thing a group ever does as the one thing one member
* decides alone.
*
* So it goes through the same door everything else the group says goes through.
* A room's key state is now a `FrostSigningEvents.PROPOSAL` over a
* [GroupKeyStateEvent], and the state exists when a quorum has signed it, on
* every device at once, authored by the group's own key. The first thing the
* group does is now something the group did.
*
* Nothing about that makes the room usable any later than before: signing falls
* back to rederiving while the session runs, which is exactly what every room
* did before this table existed.
*/
object GroupKeyStateManager {
private const val TAG = "GroupKeyStateManager"
@@ -34,34 +49,45 @@ object GroupKeyStateManager {
database.groupKeyStateDao().getByChatRoomId(chatRoomId)
/**
* Says what the freshly made room signs with, and files it locally.
* Asks the group to say what the freshly made room signs with.
*
* 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.
* Called once, by the member who created the room, as its first application
* message -- so a member arriving on a welcome finds the session waiting
* rather than having to be told about the key 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.
* No row is written here, and that is the whole change. The state is not
* this device's to assert; it appears on every device together when
* `FrostSigningManager` completes the session and applies the signed event,
* which is the same path that turns a signed proposal into a dialect.
*
* Refuses to announce a state that does not describe the room, because a
* The session signs at the room's own path, so what comes back is a state
* signed by the very key it names -- the room's id being that key. A reader
* needs nothing but the event and the room it arrived in to check that.
*
* Refuses to propose 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.
* derived the room from one key and proposed another -- a bug worth failing
* on rather than asking the group to sign.
*
* [key] is handed to the signing session rather than looked up from the
* room, because the room has no key state yet and looking one up is exactly
* what this session exists to make possible.
*/
suspend fun announce(
suspend fun propose(
database: MantraDatabase,
chatRoomId: String,
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
dkgSessionId: String,
thresholdPublicKey: HexKey,
key: DkgSession,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
createdAt: Long = Clock.System.now().epochSeconds
): GroupKeyState {
): FrostSigningSession {
val chatRoomId = localChatRoom.chatRoom.id
val thresholdPublicKey = key.thresholdPublicKey
?: throw IllegalStateException("Ceremony ${key.id} has produced no key for $chatRoomId to sign with")
val state = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
dkgSessionId = key.id,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = userPublicKey,
@@ -72,39 +98,26 @@ object GroupKeyStateManager {
"Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}"
}
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
path = path
logger.i("Proposing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}")
return FrostSigningManager.proposeSigning(
database = database,
localChatRoom = localChatRoom,
userPublicKey = userPublicKey,
kind = GroupKeyStateEvent.KIND,
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = key.id,
path = path
),
content = thresholdPublicKey,
key = key,
createdAt = createdAt
)
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.
* Files a state the group signed, 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
@@ -118,15 +131,22 @@ object GroupKeyStateManager {
stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) }
/**
* The state an announcement amounts to, or null if it amounts to none.
* The state an event 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.
* Two questions, and a state has to answer both. Is it true -- does the room
* rederive from the key it names? And did the group say it -- does the
* signature verify against the identity of that same key?
*
* The first is the one that cannot be given up. Acting on a state naming a
* key the room was not derived from 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. It is also entirely independent of who is speaking: a member
* with no share, or none of the ceremony at all, can state a true state and
* it is still true.
*
* The second is what the proposal flow buys. It does not make a state truer;
* it makes a state the group's, so that the record of what a room signs with
* is a thing a quorum agreed to rather than a thing its creator said.
*/
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags)
@@ -153,6 +173,18 @@ object GroupKeyStateManager {
return null
}
// Only the group may say what the group signs with. A member holding a
// single share cannot produce this signature, so nothing short of a
// quorum can put a room's key state on the record -- not even a member
// saying something true.
if (!GroupKeyStateEvent.isSignedByGroup(innerEvent, thresholdPublicKey, path)) {
logger.w(
"Key state in $chatRoomId from ${innerEvent.pubKey} carries no signature by " +
"the group holding $thresholdPublicKey; dropping"
)
return null
}
val state = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = dkgSessionId,
@@ -162,8 +194,8 @@ object GroupKeyStateManager {
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.
// Half the trust model, in one line, and the half that does not care who
// is speaking. Only the truth rederives the room it was stated in.
if (!state.verifies()) {
logger.w(
"Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " +

View File

@@ -179,7 +179,11 @@ object SharedKeyDerivation {
val root = PublicKey(ByteVector(thresholdPublicKey.hexToByteArray()))
var cache = TweakCache.create(root)
var current = XonlyPublicKey(root)
// Read back out of the cache rather than taken from the point, so that an
// empty path -- a group signing as its own threshold key, with nothing
// derived -- returns exactly what a session created from this cache would
// produce a signature against.
var current = cache.tweakedPublicKey
path.forEach { index ->
val scalar = tweakScalar(current, index)
@@ -201,7 +205,8 @@ object SharedKeyDerivation {
}
/**
* The Marmot `nostrGroupId` for a room derived at [path].
* The Marmot `nostrGroupId` for a room derived at [path] -- which is also the
* nostr key that room signs as.
*
* Replaces the `RandomInstance.bytes(32)` a Marmot room is normally minted
* with. Marmot asks that the id be unpredictable to outsiders, which this is --
@@ -209,6 +214,14 @@ object SharedKeyDerivation {
* buys two things random cannot: every member can compute it without being told,
* and it is a key the group can sign as, so the room's id and the identity
* behind it are the same thing.
*
* That second half is load-bearing rather than decorative. `FrostSigningManager`
* signs with the cache from [derive] at the room's own path, so every event a
* room's group signs is authored by the room's id -- and a reader checking one
* needs no lookup at all, because the author they expect is the id of the room
* they found it in. There is deliberately no second name for this value:
* "the room's id" and "the key it signs as" are one function because they are
* one key.
*/
fun marmotGroupId(
thresholdPublicKey: HexKey,

View File

@@ -1,23 +1,33 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
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.
* What key a Marmot room signs with, signed by the group whose key it is.
*
* 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
* The room's creator does not announce this; they *propose* it. What goes into
* the new room first is a [FrostSigningEvents.PROPOSAL] carrying this event
* unsigned, and the state comes into existence when a quorum has signed it --
* the same shape a dialect or an artifact is created in.
*
* ```
* creator --[ 30320 proposal over a 30326 ]-> everyone "shall we say this room signs with K?"
* ...the session runs...
* everyone --applies the signed 30326 locally-- "the group says this room signs with K"
* ```
*
* 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,
@@ -32,23 +42,31 @@ import press.mantra.compose.nostr.frost.tags.FrostKeyTag
*
* ### 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.
* Two independent checks, and a state has to pass both -- see [isSignedByGroup]
* and `GroupKeyState.verifies`.
*
* The derivation is the older one and still the load-bearing one: the room's id
* *is* the threshold key derived at the path, so
* `SharedKeyDerivation.marmotGroupId(content, path) == chatRoomId` has to hold.
* A state that fails it names a key the room was not made from, whoever signed
* it.
*
* The signature is what the proposal flow adds. A state now carries a BIP-340
* signature by the key that same derivation reaches -- which, since a room's id
* *is* that key, means a state is signed by the room it is about, and no single
* member can produce it. So a member cannot decide on their own what the room
* signs with, even truthfully. The two checks answer different questions and
* neither replaces the other: the derivation says the state is true, the
* signature says the group said it.
*
* ### 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.
* Like every kind in [FrostSigningEvents] this lives inside a Marmot group, 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 session replayed after a crash, or a second proposal for the
* same true state, 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
@@ -105,4 +123,50 @@ object GroupKeyStateEvent {
fun parseThresholdPublicKey(content: String): HexKey? =
content.trim()
.takeIf { it.length == 66 && it.all { char -> char.isDigit() || char in 'a'..'f' || char in 'A'..'F' } }
/**
* Whether the room derived from [thresholdPublicKey] at [path] actually
* signed [event].
*
* Three things, and all three are needed. The author has to be the key that
* derivation reaches, or a real signature by some other group -- or by the
* same group in another of its rooms -- would pass. The id has to be the
* hash of the fields it is sitting next to, or the signature covers a
* message that is not this event and the fields could then say anything. And
* the signature has to verify.
*
* Note what is being checked against what: the key in the *content*, walked
* to the path in the *tags*, is what has to have signed. Since that walk is
* also the room's id, the author of a state that passes is the room it
* belongs to -- so a state signed by one group about another group's key
* fails here, which is the point. Only a room may say what it signs with.
*
* Everything is caught, because every input is off the wire: a pubkey that
* is not a point, a signature that is not 64 bytes, hex that is not hex.
* All of them mean the same thing here, which is no.
*/
fun isSignedByGroup(
event: Event,
thresholdPublicKey: HexKey,
path: List<Long>
): Boolean = runCatching {
val author = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
if (!event.pubKey.equals(author, ignoreCase = true)) return false
val hashes = EventHasher.hashIdCheck(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
kind = event.kind,
tags = event.tags,
content = event.content
)
if (!hashes) return false
Nip01Crypto.verify(
signature = event.sig.hexToByteArray(),
hash = event.id.hexToByteArray(),
pubKey = event.pubKey.hexToByteArray()
)
}.getOrDefault(false)
}

View File

@@ -2,7 +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.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgApprovalStep
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -44,17 +44,20 @@ interface DkgRepository {
)
/**
* Tells [chatRoomId] which ceremony's key it signs with, as its first message.
* Asks the room to sign a statement of 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.
* Called once by whoever creates the room. What comes back is the signing
* session, not the state: the state exists when a quorum has signed, which
* is the point of proposing it rather than announcing it. 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 propose.
*/
suspend fun announceGroupKeyState(
chatRoomId: String,
suspend fun proposeGroupKeyState(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState?
): FrostSigningSession?
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
@@ -80,11 +83,11 @@ interface DkgRepository {
nostrPrivateKey: ByteArray
) = Unit
override suspend fun announceGroupKeyState(
chatRoomId: String,
override suspend fun proposeGroupKeyState(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
session: DkgSession
): GroupKeyState? = null
): FrostSigningSession? = null
}
}
}

View File

@@ -41,7 +41,8 @@ interface FrostSigningRepository {
/**
* Opens a session asking the group to sign an event with these fields. The
* author is the group's key, not the proposer's, and is filled in here.
* author is the room's own key, not the proposer's, and is filled in here --
* the room's id, since signing runs at the path the room was derived at.
*/
suspend fun proposeSigning(
localChatRoom: LocalChatRoom,

View File

@@ -49,6 +49,8 @@ import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
@@ -319,6 +321,15 @@ private fun WhatIsBeingSigned(event: Event?) {
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).name().orEmpty()
// The one thing a group signs that is about the group rather than about
// its work, and the only one a member sees before the room has done
// anything. Shown as the path and the ceremony rather than as the key:
// the key is the room's own id, which the member is already looking at.
GroupKeyStateEvent.KIND -> "This group's shared key" to listOfNotNull(
GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath),
GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12)
).joinToString(" · ")
else -> "Event of kind ${event.kind}" to event.content
}

View File

@@ -66,8 +66,10 @@ class AddArtifactViewModel(
*
* The artifact is not created here and does not exist yet. What goes out is
* a proposal to sign it, and the artifact appears -- on every member's
* device at once, authored by the group's shared key rather than by whoever
* typed it -- when enough members have signed.
* device at once, authored by this room's own key rather than by whoever
* typed it -- when enough members have signed. That author is the room's id:
* signing runs at the path the room was derived at, so an artifact says
* which library it belongs to simply by being signed.
*
* That is the difference from submitting one. A submission says "I am
* putting this in front of the group" and the group's only recourse

View File

@@ -69,8 +69,10 @@ class AddDialectViewModel(
*
* The dialect is not created here and does not exist yet. What goes out is a
* proposal to sign it, and the dialect appears -- on every member's device at
* once, authored by the group's shared key rather than by whoever typed it --
* when enough members have signed.
* once, authored by this room's own key rather than by whoever typed it --
* when enough members have signed. That author is the room's id: signing runs
* at the path the room was derived at, so a dialect says which room made it
* simply by being signed.
*
* That is the difference from submitting one. A submission says "I am putting
* this in front of the group" and the group's only recourse afterwards is

View File

@@ -384,24 +384,34 @@ 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.
// The room's first message: a proposal that the group sign a
// statement of 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.
//
// Proposed rather than declared, so that the first thing on the
// record in a group's own room is a thing the group did. Nothing
// waits on it: until a quorum signs, a signing request resolves the
// key by rederiving, which is what every room did before this event
// existed.
//
// 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.
// at. Proposed first, the proposal would reach nobody but its author
// -- and a proposal nobody receives is one nobody can sign. 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,
// A member invited later misses the session for the same reason, and
// so misses the state: a completed session is applied by each device
// that ran it and the signed event never goes on the wire. They are
// left where every member was before this event existed, falling back
// to FrostSigningManager.completedKey's rederivation. Re-proposing on
// invite is the fix, and is cheap because a second signature over the
// same true state folds away rather than accumulating.
dkgRepository.proposeGroupKeyState(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
session = session
)

View File

@@ -51,10 +51,23 @@ class FrostSigningRoundTest {
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/**
* The room the group signs in, derived at the admin path.
*
* The cache carries the path's tweaks, and every call below is given it
* rather than a bare `TweakCache.create` -- because that is what the manager
* does, and because a tweaked cache is precisely the part most likely to be
* wired up wrong without saying so.
*/
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity: the room's own id, which is what it signs as. */
private val groupPubKey = room.hex
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
private fun eventId(content: String): String = EventHasher.hashId(
@@ -116,7 +129,7 @@ class FrostSigningRoundTest {
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"the aggregated signature must verify against the group's x-only key"
"the aggregated signature must verify against the room's own key"
)
}
@@ -336,10 +349,15 @@ class FrostSigningCompletionTest {
threshold = 2
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity, exactly as `unsignedEventOf` derives it. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private val groupPubKey = room.hex
/** The event the group is asked to sign, built the way the manager builds it. */
private val unsignedEvent = Event(
@@ -406,6 +424,7 @@ class FrostSigningCompletionTest {
threshold = 2,
participantCount = 3,
signerId = 2,
derivationPath = SharedKeyDerivation.formatPath(),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64),

View File

@@ -1,41 +1,75 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
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.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.secp256k1.Hex
import fr.acinq.bitcoin.XonlyPublicKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
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.FrostSigningSession
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.
* What a room's key state 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.
* Two independent things have to hold, and the tests come in those two halves.
*
* A state has to be *true*: the room's id is the threshold key derived at the
* path, so a state that does not rederive its own room is naming a key the room
* was not made from. A member who acts on one signs with a share that cannot
* aggregate, or worse, treats a key the group does not hold as the key the group
* holds. That check knows nothing about who is speaking and never did.
*
* And a state has to be the *group's*: it is now produced by a FROST signing
* session rather than announced by whoever made the room, so it carries a
* signature no single member can make. That does not make a state truer -- the
* derivation already settled truth -- it makes the first thing on a group's
* record something the group agreed to.
*
* `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.
* can be is the deciding it defers to, which is where both halves live.
*/
class GroupKeyStateTest {
/** Stands in for a ceremony's output. Any valid point will do. */
private val thresholdPublicKey = PrivateKey(
Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
).publicKey().value.toHex()
private val participants = 3
private val threshold = 2
/** A second group's key, for the states that name the wrong one. */
private val otherKey = PrivateKey(
Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2")
).publicKey().value.toHex()
/** Stands in for a completed ceremony. Any valid key will do. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
/** A second group, entirely: its own key, its own shares, its own quorum. */
private val otherKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2")
),
nParticipants = participants,
threshold = threshold
)
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
/** The other group's key, for the states that name the wrong one. */
private val otherKey = otherKeyMaterial.thresholdPublicKey.value.toHex()
private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
@@ -61,9 +95,9 @@ class GroupKeyStateTest {
@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.
// The attack this is here for: a room pointed 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())
}
@@ -89,7 +123,7 @@ class GroupKeyStateTest {
}
@Test
fun `the tags a state is announced on read back as they were written`() {
fun `the tags a state is proposed on read back as they were written`() {
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
@@ -136,79 +170,272 @@ class GroupKeyStateTest {
)
}
// ---- The announcement as it actually arrives -------------------------
// ---- The state as the group actually makes one -----------------------
//
// 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.
// check the assembling: a real Event, signed by a real quorum against real
// FROST, through the function the inbound path calls.
private fun announcement(
/**
* A quorum of [material]'s group signing [eventId], in the manager's order.
*
* The same shape `FrostSigningManager.advance` runs -- nonces, a signer set,
* partial signatures, an aggregate -- because a signature assembled any
* other way would not be evidence that this check accepts the ones the app
* produces.
*/
private fun groupSignature(material: KeyMaterial, eventId: String): String =
groupSignatureAt(material, path, eventId)
private fun groupSignatureAt(
material: KeyMaterial,
path: List<Long>,
eventId: String
): String {
val cache = identityOf(material, path).cache
val message = ByteVector(eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = material.secretShares[signerId],
publicShare = material.publicShares[signerId],
tweakedThresholdPublicKey = cache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { material.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = cache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
material.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/**
* A group's room at [path]: the cache a signature is made with and the key it
* verifies against, which is also the room's own id.
*/
private fun identityOf(
material: KeyMaterial,
path: List<Long> = this.path
): SharedKeyDerivation.Derived =
SharedKeyDerivation.derive(material.thresholdPublicKey.value.toHex(), path)
/**
* The event a proposal carries: the key state re-authored under the room's
* key, exactly as `FrostSigningManager.unsignedEventOf` does it.
*/
private fun unsignedKeyState(
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 = ""
createdAt: Long = 1_700_000_000,
author: KeyMaterial = keyMaterial
): Event {
val groupPubKey = identityOf(author).hex
return Event(
id = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content
),
pubKey = groupPubKey,
createdAt = createdAt,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = content,
sig = ""
)
}
/**
* A finished key state, through the same call that hands a completed session
* its event -- so what these tests judge is what a device actually applies.
*/
private fun signedKeyState(
content: String = thresholdPublicKey,
tags: Array<Array<String>> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
createdAt: Long = 1_700_000_000,
author: KeyMaterial = keyMaterial,
signer: KeyMaterial = author
): Event {
val unsigned = unsignedKeyState(content, tags, createdAt, author)
return FrostSigningManager.signedEvent(
session = sessionOver(unsigned),
signature = groupSignature(signer, unsigned.id)
)
}
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = "c00rd1na70r",
userPublicKey = "c00rd1na70r",
dkgSessionId = "ceremony-1",
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(path),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
)
@Test
fun `an announcement of the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement())
fun `a state the group signed about the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
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)
// Attribution and ordering come off the event, not off the clock. The
// author is the room itself now, which is the whole point: nobody in
// particular said this.
assertEquals(chatRoomId, 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)))
fun `a true state nobody signed is dropped`() {
// The change this file exists to pin down. What is being refused here is
// not a lie -- this state is perfectly true and rederives its own room --
// it is one member deciding alone what the group signs with. That used to
// be enough and is not any more.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, unsignedKeyState()))
}
@Test
fun `an announcement addressed to another room is dropped`() {
fun `a state a member signed for themselves is dropped`() {
// The obvious way around the check: sign a true state with your own nostr
// key and hope the author is not looked at. The author has to be the
// identity of the key in the content, and a member is not that.
val member = PrivateKey(
ByteVector32("3c0ffee0000000000000000000000000000000000000000000000000000000c3")
)
val memberPubKey = XonlyPublicKey(member.publicKey()).value.toHex()
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
val id = EventHasher.hashId(
pubKey = memberPubKey,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey
)
val signed = Event(
id = id,
pubKey = memberPubKey,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey,
sig = Nip01Crypto.sign(
data = id.hexToByteArray(),
privKey = member.value.toByteArray()
).toHex()
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signed))
}
@Test
fun `a state one group signed about another group's key is dropped`() {
// Only a group may say what its own key is. This one is signed, by a real
// quorum, and still refused: the signature is not by the key it names.
assertNull(
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(author = otherKeyMaterial))
)
}
@Test
fun `a state edited after the group signed it is dropped`() {
// A signature covers the id, and the id is the hash of the fields. Swap
// a field and the two stop agreeing, which is what the id check is for.
val signed = signedKeyState()
val tampered = Event(
id = signed.id,
pubKey = signed.pubKey,
createdAt = signed.createdAt,
kind = signed.kind,
tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-2", path),
content = signed.content,
sig = signed.sig
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, tampered))
}
@Test
fun `a state naming another group's key is dropped`() {
// Well-formed input that lies. Refused by the derivation rather than by
// the signature -- the group here is signing about its own key, it is
// simply not the key this room was made from.
val siblingRoom = SharedKeyDerivation.marmotGroupId(otherKey, path)
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
signedKeyState(
content = otherKey,
tags = GroupKeyStateEvent.assembleTags(siblingRoom, "ceremony-1", path),
author = otherKeyMaterial
)
)
)
}
@Test
fun `a state addressed to another room is dropped`() {
val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere)))
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = elsewhere)))
}
@Test
fun `an announcement missing any of what it has to say is dropped`() {
fun `a state 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())
signedKeyState(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())
signedKeyState(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray())
)
)
// No key.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = "")))
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(content = "")))
}
@Test
fun `an announcement carrying no d tag is judged on its derivation alone`() {
fun `a state 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.
@@ -219,7 +446,7 @@ class GroupKeyStateTest {
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId
)
}
@@ -242,4 +469,53 @@ class GroupKeyStateTest {
// else defers to, and it should not be the thing that trusts its input.
assertFalse(state(derivationPath = "m/4294967296/0/0").verifies())
}
@Test
fun `the room signs as its own id`() {
// The whole point of deriving at the room's path. The author of a state
// is the room it belongs to, so a reader checking one needs no lookup:
// the key they expect is the id of the room they found it in.
assertEquals(chatRoomId, signedKeyState().pubKey)
// And it verifies as that key, which is the half a signature makes true
// rather than merely stated.
val signed = signedKeyState()
assertTrue(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = signed.id.hexToByteArray(),
pubKey = chatRoomId.hexToByteArray()
)
)
}
@Test
fun `a state signed at the wrong path is dropped`() {
// The path decides which key the group signs as, so a session that walked
// a different one produces an author this room does not answer to -- even
// with the right group, the right key and a real quorum behind it.
val sibling = listOf(9420L, 0L, 1L)
val siblingIdentity = identityOf(keyMaterial, sibling)
val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
val id = EventHasher.hashId(
pubKey = siblingIdentity.hex,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey
)
val signed = Event(
id = id,
pubKey = siblingIdentity.hex,
createdAt = 1_700_000_000,
kind = GroupKeyStateEvent.KIND,
tags = tags,
content = thresholdPublicKey,
sig = groupSignatureAt(keyMaterial, sibling, id)
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signed))
}
}

View File

@@ -1,12 +1,16 @@
package press.mantra.compose.managers
import press.mantra.compose.extensions.toHex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import press.mantra.compose.database.model.FrostSigningSession
/**
* The derivation every member's device has to agree on, run against real FROST
@@ -125,4 +129,50 @@ class SharedKeyDerivationTest {
assertEquals(derived.publicKey, derived.cache.tweakedPublicKey)
}
@Test
fun `a walk of no steps lands on the threshold key itself`() {
// What a signing session with no path signs as: a row written before that
// column existed, or a room that is not derived from the key at all. Both
// have to reach the untweaked key, or a session caught by the migration
// would change which key it signs as between two of its own rounds.
val nowhere = SharedKeyDerivation.derive(thresholdPublicKey, emptyList())
assertEquals(
PublicKey(ByteVector(thresholdPublicKey.hexToByteArray())).xOnly().value.toHex(),
nowhere.hex
)
assertEquals(nowhere.publicKey, nowhere.cache.tweakedPublicKey)
// And it is not any room: the admin path walks somewhere else entirely.
assertNotEquals(nowhere.hex, SharedKeyDerivation.marmotGroupId(thresholdPublicKey))
}
@Test
fun `a session with no derivation path walks nowhere`() {
// The column is nullable and null means "no derivation", which has to read
// back as the empty walk above rather than as the default path.
assertEquals(emptyList(), session(derivationPath = null).pathIndices())
assertEquals(
SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
session(derivationPath = "m/9420/0/0").pathIndices()
)
// An unwalkable string is not a path anybody signed at either.
assertEquals(emptyList(), session(derivationPath = "m/9420'/0/0").pathIndices())
}
private fun session(derivationPath: String?) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
dkgSessionId = "k".repeat(64),
threshold = 2,
participantCount = 3,
signerId = 0,
derivationPath = derivationPath,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64)
)
}

View File

@@ -40,7 +40,6 @@ import press.mantra.compose.nostr.nip30303.ArtifactEvent
class SignedArtifactTest {
private val participants = 3
private val threshold = 2
private val chatRoomId = "room"
/** The member who filled in the form. Nothing they own should end up on the row. */
private val proposer = "9".repeat(64)
@@ -55,10 +54,21 @@ class SignedArtifactTest {
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/**
* The room the group signs in: derived from its key at the admin path, which
* is what makes the room's id and the key it signs as one value.
*/
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity here, which is also [chatRoomId]. */
private val groupPubKey = room.hex
private val chatRoomId = room.hex
private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build(
name = "In Detention",
@@ -99,6 +109,7 @@ class SignedArtifactTest {
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
@@ -160,6 +171,22 @@ class SignedArtifactTest {
assertNotEquals(proposer, artifact.publicKey)
}
@Test
fun `the group it is authored by is the room it was signed in`() {
// Signing runs at the room's derivation path, so the author is the room's
// own id rather than the bare threshold key. That is what lets anybody
// holding the row check it without being told which key to expect -- and
// it is why the path a session signs at cannot come from the proposer.
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
assertEquals(chatRoomId, artifact?.publicKey)
assertNotEquals(
keyMaterial.thresholdPublicKey.xOnly().value.toHex(),
artifact?.publicKey,
"an artifact must be signed by the room's key, not by the group's root key"
)
}
@Test
fun `the row's id is the id the group put its signature to`() {
// Every device builds this row from the same signed event, so the id has