feat: sign a group's key state before its room exists, and put FROST on NIP-17

A room's `GroupKeyState` was the new #admins room's first application message:
the coordinator created the room, added the members, and only then asked the
group to agree what it signs with. The order is now reversed. The group agrees
it while it is still just a ceremony and a NIP-17 chat, and the room is created
already knowing.

**Two things were wrong with the old order, and neither was cosmetic.** The
room's founding fact was settled after the founding, so a session that never
reached a quorum left a live room whose every member fell back to rederiving --
which works, but only at the one path the constant names, and says nothing about
which ceremony a device should take its share from. And the members who had to
sign it were exactly the ones the room had just been created to hold: a member
whose key package could not be found was excluded from the room *and* from a
decision they held a share of, while `createAdminGroup` refuses to create the
room at all in that case. Agreeing first makes the state a precondition of the
room rather than an afterthought.

**Signing therefore has to work in a NIP-17 room, and `broadcast` is the only
place that knows.** In a Marmot room a signing message stays an ordinary inner
event, encrypted to the group and addressed to nobody, because who is in the
group is the MLS tree's business. In a NIP-17 room it goes out as one sealed
gift wrap per member and has to name them all, or the members it left out never
hear. Neither shape lets a recipient list decide anything -- the signer set
comes from the ceremony's host keys either way -- so tagging somebody does not
put them in it and failing to tag somebody only stops them hearing. Everything
above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds
off the gift-wrap path beside the DKG's, and the outbound path needed no change
because `sealGiftWrapPayload` already seals to the room's participants and
already refuses MLS rooms.

**`signingPath` gains the one case that cannot be self-checked.** Every other
candidate is right exactly when walking it reaches the room, which makes the
resolution self-checking rather than trusting. A NIP-17 room's id is an
aggregation of its members' keys, so no path reaches it and nothing can be
checked that way. What the group signs as there is the room it is about to make:
the ceremony's key at the app's admin path. That is admitted only when the
ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this
device's database -- and the path is the constant rather than anything off the
wire, so a proposer still chooses nothing. Naming some other ceremony this
device holds a share for gets no path at all, and `completedKey` will not even
find a key for a NIP-17 room that did not host one, so such a room cannot open a
session; both are tested.

**A state's subject is now its own `d` tag, not the room it arrived in.** Those
used to be required to agree, and a mismatch was dropped -- the right rule while
a state was made in the room it described, and the wrong one now that the two
differ by design. Nothing is given up. The check that drop was standing in for
is still made and made against the *named* room: `GroupKeyState.verifies` has to
rederive it, and `isSignedByGroup` has to find a signature by the key that
rederivation reaches. A state can therefore only ever be about a room it
derives, whatever room it turned up in, so nobody can point one room at another
room's key by putting it through the wrong door. The arrival room survives only
as the fallback for a state carrying no `d` tag at all.

**`record` holds what it cannot file; `adopt` files it when there is a room.**
`GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room
exists has nothing to hang on -- which is now the normal case rather than an
error. `record` says so and keeps the signed event; `adopt` reads it back off
`GroupSignedEvent` and files it the moment a room appears. Both ways into a room
end there: the member who creates it, in `createAdminGroup` and before the
members are added, since filing is local and doing it while the room is certain
to exist beats doing it after a step that can partly fail; and the member who
arrives on a Welcome, in `NostrDao`, off the same event they were already
holding because it was signed in the room they were already in. Nothing goes on
the wire in either case. A member who was not in the ceremony holds no such
event and gets nothing, which is right -- they hold no share either, so there is
nothing for them to pick the wrong one of.

**The screen watches the signed event, not a state row, and that is not
interchangeable.** There is no row until there is a room, so the only thing that
can say the agreement was reached is the event. `observeSignedGroupKeyState`
is a flow over `GroupSignedEvent` by kind for the same reason the button it
gates exists. Gating on the session's own items instead was rejected twice over:
`complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a
collector woken by the session row can read before the event lands; and an item
can hold a signature that has not been verified yet -- `complete` is where each
one is checked against its id and author, and throws if it is not.

**The button is one control and two steps, in the order they have to happen.**
"Agree the group's signing key" until a quorum has signed, "Create the #admins
group" after. Offering both at once would be the old order still available, and
`createAdminGroup` refuses it in the view model as well, since the screen not
drawing something is not a guard. A failed session re-offers the propose button
and nothing else does, because a retry has to be a *new* session: the failed
one's nonce seeds have already been published against an aggregate, and reusing
one produces two partial signatures under a single secret nonce, which is how a
share is extracted. `propose` mints a fresh session id every time, so tapping it
is the safe retry by construction.

**One bug found in review, which the tests now pin.** `replayStoredMessages`
read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before
the proposal it belongs to -- routine on a fresh sync, where a relay hands over a
backlog in whatever order it likes -- was stored in the gift-wrap payloads and
never read back. It now reads whichever store the room's transport writes to,
which has to be the same reading `broadcast` makes. `a nonce arriving before the
proposal is replayed out of the gift wraps` fails against the old code.

**One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a
signature was made in, which for every event but this one is also the room whose
key signed it. The key state is filed under the ceremony's room and authored by
the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row --
check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the
row cannot. Both columns are documented to say so. Re-filing the row under the
#admins room once it exists was the alternative and buys nothing: a key state is
not chroniclable, so no reader wants it there, and moving a row to keep one
helper honest is worse than saying where the helper stops.

`ChronicleManager` and `docs/member-chronicle.md` both argued for the
`isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its
first act", which is no longer true of any Marmot room. The filter stays and the
argument is restated: what it stops is a member replaying any group-signed
statement *about* the record as though it were work, and `applyPage` refuses the
same kinds coming the other way. The two are a pair and neither is safe to drop
on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state
on file by hand, which makes that test sharper rather than hypothetical.

`SignedGroupKeyStateTest`'s harness flattens the two transports into one
`Queued` shape and each device declares whether its room has MLS state, so every
existing test keeps testing the Marmot path and the seven new ones read the
same. `GroupKeyStateTest`'s "a state naming another group's key is dropped"
splits in two: one holding the room fixed and varying the key, which is still a
drop, and one varying both, which is another group's true statement and is now
attributed to that group's room rather than refused.

649 jvm tests and 373 common tests pass; `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 21:15:58 +02:00
parent 3382586501
commit 113eda9f4d
20 changed files with 1399 additions and 282 deletions

View File

@@ -58,6 +58,11 @@
<string name="create_profile">Create profile</string>
<string name="create_project">Create project</string>
<string name="create_the_admins_group">Create the #admins group</string>
<string name="agree_the_groups_signing_key">Agree the group's signing key</string>
<string name="the_group_is_agreeing_what_the_admins_room">The group is agreeing what the #admins room will sign with. It takes %1$s of %2$s members, and the request is in this chat.</string>
<string name="the_group_has_agreed_what_the_admins_room">The group has agreed what the #admins room will sign with.</string>
<string name="the_group_could_not_agree_what_the_admins">The group could not agree what the #admins room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
<string name="before_the_room_exists_the_group_signs">Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it.</string>
<string name="creating_new_chat">Creating new chat.</string>
<string name="currently_no_contacts_please_search_and_chat">Currently no contacts. Please search and chat with a few people.</string>
<string name="currently_no_messages_have_been_shared">Currently no messages have been shared.\nBreak the ice.</string>

View File

@@ -7,6 +7,7 @@ import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Clock
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.GroupSignedEvent
@Dao
@@ -27,6 +28,28 @@ abstract class GroupSignedEventDao {
@Query("SELECT * FROM GroupSignedEvent WHERE chatRoomId = :chatRoomId AND kind = :kind ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByChatRoomIdAndKind(chatRoomId: String, kind: Kind): List<GroupSignedEvent>
/**
* Every signed event of one kind, whatever room it was signed in.
*
* The one query here that is not about a room, and it exists for the one
* statement that is not made in the room it is about: a group signs its
* `GroupKeyStateEvent` in the NIP-17 room its ceremony ran in, naming the
* Marmot room that does not exist yet -- see `GroupKeyStateManager.adopt`,
* which walks these to find the state for a room the moment there is one.
*/
@Query("SELECT * FROM GroupSignedEvent WHERE kind = :kind ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByKind(kind: Kind): List<GroupSignedEvent>
/**
* The same, as it changes.
*
* Watched by the ceremony screen, which has to know the moment the group
* finishes agreeing its key state -- there being no room yet, and so no
* `GroupKeyState` row, to watch instead.
*/
@Query("SELECT * FROM GroupSignedEvent WHERE kind = :kind ORDER BY createdAt ASC, id ASC")
abstract fun observeByKind(kind: Kind): Flow<List<GroupSignedEvent>>
/** Everything one session signed, in the order the batch was proposed in. */
@Query("SELECT * FROM GroupSignedEvent WHERE frostSigningSessionId = :sessionId ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByFrostSigningSessionId(sessionId: String): List<GroupSignedEvent>

View File

@@ -34,6 +34,7 @@ import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
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
@@ -821,6 +822,23 @@ abstract class NostrDao(
}
}
// The room arrives already knowing what it
// signs with, if this device was in the
// ceremony. The state was signed in the NIP-17
// room the ceremony ran in, before this room
// existed to hold it, so joining is the first
// moment there is a row to file it against --
// see `GroupKeyStateManager.adopt`.
//
// Nothing for a member who was not in the
// ceremony: they hold no signed state and no
// share either, so there is nothing for them to
// pick the wrong one of.
GroupKeyStateManager.adopt(
database = database,
chatRoomId = nostrGroupId
)
// Where is the decoded welcome message...
// TODO: Sync GroupEvents/Messages...
// database.chatMessageDao().upsert(
@@ -865,6 +883,49 @@ abstract class NostrDao(
nostrPrivateKey = activeKeyPair.privKey!!
)
}
} else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) {
// A FROST signing message for one of our NIP-17
// groups, which is where a group makes its first
// signature: the statement of what its #admins room
// signs with, agreed before that room is created.
// See `GroupKeyStateManager.propose`.
//
// The room is created on demand for the same reason
// a ritual message creates one, and the manager is
// idempotent, so a redelivered message re-runs a step
// it has already taken.
val localChatRoom = getOrCreateNip17ChatRoom(
decryptedGiftWrapPayload = decryptedGiftWrapPayload,
activeKeyPair = activeKeyPair,
nostrEventId = nostrEvent.id,
relayURL = relayURL
)
if (localChatRoom == null) {
logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}")
} else {
FrostSigningManager.processSigningPayload(
database = database,
localChatRoom = localChatRoom,
// The rumor as its sender wrote it. `sig` is
// empty because a gift-wrapped rumor carries
// none -- the seal is what vouches for the
// author -- and nothing in a signing session
// reads it: what a session puts a signature
// to is the event inside the proposal, not
// the message carrying it.
innerEvent = Event(
id = decryptedGiftWrapPayload.id,
pubKey = decryptedGiftWrapPayload.publicKey,
createdAt = decryptedGiftWrapPayload.createdAt.epochSeconds,
kind = decryptedGiftWrapPayload.kind,
tags = decryptedGiftWrapPayload.tags,
content = decryptedGiftWrapPayload.content,
sig = ""
),
userPublicKey = activeKeyPair.pubKey.toHex()
)
}
} else {
logger.w("Unsupported event: $decryptedGiftWrapPayload")
}

View File

@@ -1211,15 +1211,20 @@ data class ChatMessage(
// account of the same thing.
in FrostSigningEvents.ALL -> null
// The group saying what key its room signs with, arriving here
// the way every group-signed event does: applied by
// The group saying what key one of its rooms 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.
// [groupId] is where the signing happened, which is usually not
// the room the state is about -- a group agrees this before the
// room exists, so there is routinely nothing to write yet and
// `GroupKeyStateManager.adopt` files it when the room appears.
//
// Recorded rather than believed either way. `GroupKeyStateManager`
// keeps a state only if the room it *names* 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

View File

@@ -21,10 +21,13 @@ import press.mantra.compose.managers.SharedKeyDerivation
* 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.
* The row appears as the room does. The group agrees the state before the room
* exists -- in the NIP-17 room its ceremony ran in -- and every device that took
* part files it the moment it has a room to file it against, whether it created
* the room or was handed it by a Welcome. See `GroupKeyStateManager.adopt`.
*
* A device that was not in the ceremony holds 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
*

View File

@@ -71,7 +71,16 @@ data class GroupSignedEvent(
@PrimaryKey
val id: HexKey,
/** The room whose key signed it. Its id *is* [publicKey] for a derived room. */
/**
* The room the signature was made in. Its id *is* [publicKey] for a derived
* room, which is every room but one.
*
* The exception is a group's first signature: its `GroupKeyStateEvent` is
* signed in the NIP-17 room the ceremony ran in, and is authored by the
* #admins room that does not exist yet -- so this names where it happened
* and [publicKey] names who signed. [verifies] cannot pass on that row, for
* the reason it gives.
*/
val chatRoomId: String,
/** The author: the group's key at [derivationPath], x-only 32-byte hex. */
@@ -134,14 +143,21 @@ data class GroupSignedEvent(
*
* **False does not always mean the row is corrupt.** The usual reading is
* that its columns have drifted from the event they came from, and that is
* the case worth acting on. But a room that was never derived from its
* group's key -- [derivationPath] null, the legacy shape `signingPath`
* documents -- signs as the bare threshold key rather than as its own id, so
* a perfectly good event in such a room fails here and cannot be made to
* pass: the key it would have to be checked against is not on the row and
* cannot be walked back to from one. Those rooms get an empty chronicle for
* the same reason, which is a limit of the derivation rather than of this
* check.
* the case worth acting on. Two kinds of row fail honestly.
*
* A room that was never derived from its group's key -- [derivationPath]
* null, the legacy shape `signingPath` documents -- signs as the bare
* threshold key rather than as its own id, so a perfectly good event in such
* a room fails here and cannot be made to pass: the key it would have to be
* checked against is not on the row and cannot be walked back to from one.
* Those rooms get an empty chronicle for the same reason, which is a limit
* of the derivation rather than of this check.
*
* And a group's first signature is made in the NIP-17 room its ceremony ran
* in, about the #admins room it is about to create, so [chatRoomId] is where
* it happened and [publicKey] is who signed. Check that one with
* `GroupKeyStateEvent.isSignedByGroup`, which asks the question this row
* cannot: whether the *named* room signed it.
*/
fun verifies(): Boolean = GroupKeyStateEvent.isSignedByRoom(toEvent(), chatRoomId)

View File

@@ -4,15 +4,20 @@ 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.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.repository.DkgRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class DatabaseDkgRepository(
private val database: MantraDatabase,
@@ -62,12 +67,42 @@ class DatabaseDkgRepository(
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)
// A room with no standing 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 the screen reports a proposal that did not open by leaving
// the group with nothing signed and the button still there.
logger.e("Error proposing the key state from ${localChatRoom.chatRoom.id}", e)
null
}
override fun observeSigningSessions(
chatRoomId: String
): Flow<List<LocalFrostSigningSession>> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
override fun observeSignedGroupKeyState(chatRoomId: String): Flow<GroupKeyState?> =
database.groupSignedEventDao()
.observeByKind(GroupKeyStateEvent.KIND)
.map { signedEvents ->
// The room the ceremony's key derives, resolved per emission
// rather than once: the ceremony has no key until it finishes,
// and this flow is running before it does.
val adminRoomId = database.dkgSessionDao()
.getLatestSessionForChatRoom(chatRoomId)
?.thresholdPublicKey
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
adminRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
}
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try {
GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId)
} catch (e: Throwable) {
// The room is made and usable either way; what it loses is the shortcut
// from room to ceremony, and `FrostSigningManager.completedKey` still
// rederives its way to the same key.
logger.e("Error adopting the signed key state for $chatRoomId", e)
null
}

View File

@@ -50,11 +50,18 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
*
* **The allowlist does real work on the way out now.** The rebuild could only
* ever produce document kinds; the table holds everything the group has ever
* signed, `GroupKeyStateEvent` included -- and every room signs one of those as
* its first act. So [signedEventsOf] filters on [ChronicleEvent.isChroniclable]
* before anything else, which is the same rule [applyPage] applies on the way
* in. Without it `ChronicleEvent.build` would refuse the page, and a room's whole
* chronicle would fail on the one event every room has.
* signed, `GroupKeyStateEvent` included. So [signedEventsOf] filters on
* [ChronicleEvent.isChroniclable] before anything else, which is the same rule
* [applyPage] applies on the way in. Without it `ChronicleEvent.build` would
* refuse the page and take the whole chronicle down with it.
*
* A room's own key state no longer sits in that table under the room -- it is
* signed in the NIP-17 room the ceremony ran in, before the room exists, and
* filed under that room; see `GroupKeyStateManager`. That makes the filter a
* narrower guard than it was and not a redundant one: what it is really stopping
* is a member replaying any group-signed statement about the record as though it
* were work, and [applyPage] refuses the same kinds coming the other way. The
* two are a pair and neither is safe to drop on the strength of the other.
*
* ### Nothing unverifiable leaves
*

View File

@@ -8,6 +8,8 @@ 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.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
@@ -26,6 +28,7 @@ import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MarmotInnerEvent
@@ -37,7 +40,7 @@ import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
/**
* Signs a nostr event with a group's FROST threshold key, in its #admins room.
* Signs a nostr event with a group's FROST threshold key, in one of its rooms.
*
* The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes
* a signature coordinates it, protocol messages travel on the kinds in
@@ -46,14 +49,19 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
* so a device killed mid-round resumes on the next message. What that manager's
* own notes say about being message-driven applies here unchanged.
*
* The transport is not the same one. A ceremony runs over NIP-17 because it has
* to: its participants are not yet a Marmot group, and its whole purpose is to
* produce the key one would be keyed on. Signing has the opposite problem
* solved for it -- the #admins room already exists, its membership is exactly
* the share holders, and its id is derived from the key -- so a signing message
* is an ordinary Marmot inner event and needs no addressing of its own. One
* encrypted group event reaches everyone, rather than one sealed wrap per
* member per message.
* ### Two transports, because a group signs before it has a room
*
* Almost every signature is made in the group's #admins room, and there a
* signing message is an ordinary Marmot inner event needing no addressing of its
* own: the room already exists, its membership is exactly the share holders, and
* its id is derived from the key. One encrypted group event reaches everyone.
*
* The exception is the group's *first* signature. A room's `GroupKeyState` is
* now signed before the room it is about is created -- see
* `GroupKeyStateManager.propose` -- so that session runs in the NIP-17 room the
* ceremony ran in, where there is no MLS tree and a message goes out as one
* sealed gift wrap per member. [broadcast] is the only place that knows the
* difference; everything above it is the same protocol either way.
*
* ### What it signs as
*
@@ -64,6 +72,10 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
* 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.
*
* A session in a NIP-17 room signs as the room the ceremony's key derives --
* the #admins room that does not exist yet -- which is the same rule read
* forwards: what a group signs as is the room the signature belongs to.
*
* The path comes from the room, never from a proposal -- [signingPath] -- because
* it decides which key the group signs as.
*
@@ -467,7 +479,7 @@ object FrostSigningManager {
)
announceApprovalNeeded(database, session)
replayStoredMessages(database, session)
replayStoredMessages(database, localChatRoom, session)
return session
}
@@ -478,27 +490,52 @@ object FrostSigningManager {
* They were dropped at the time for want of a session to file them under, but
* the inbound path stores every payload it decrypts before dispatching on
* kind, so nothing was actually lost — this reads them back out.
*
* Out of whichever store the room's transport writes to, which has to be the
* same reading [broadcast] makes: a session in a NIP-17 room has its backlog
* in the gift-wrap payloads and none at all in the inner events, and looking
* in the wrong one is a stall with nothing to blame it on.
*/
private suspend fun replayStoredMessages(
database: MantraDatabase,
localChatRoom: LocalChatRoom,
session: FrostSigningSession
) {
val stored = database.marmotInnerEventDao().getByChatRoomAndKinds(
chatRoomId = session.chatRoomId,
kinds = FrostSigningEvents.ALL.toList()
).filter { stored ->
val kinds = FrostSigningEvents.ALL.toList()
val received = if (localChatRoom.chatRoom.mlsGroupState != null) {
database.marmotInnerEventDao()
.getByChatRoomAndKinds(chatRoomId = session.chatRoomId, kinds = kinds)
.map { stored ->
Event(
id = stored.id,
pubKey = stored.publicKey,
createdAt = stored.createdAt.epochSeconds,
kind = stored.kind,
tags = stored.tags,
content = stored.content,
sig = ""
)
}
} else {
database.giftWrapPayloadDao()
.getByChatRoomAndKinds(chatRoomId = session.chatRoomId, kinds = kinds)
.map { stored ->
Event(
id = stored.id,
pubKey = stored.publicKey,
createdAt = stored.createdAt.epochSeconds,
kind = stored.kind,
tags = stored.tags,
content = stored.content,
sig = ""
)
}
}
val stored = received.filter { stored ->
stored.kind != FrostSigningEvents.PROPOSAL &&
FrostSigningEvents.parseSessionId(stored.tags) == session.id
}.map { stored ->
Event(
id = stored.id,
pubKey = stored.publicKey,
createdAt = stored.createdAt.epochSeconds,
kind = stored.kind,
tags = stored.tags,
content = stored.content,
sig = ""
)
}
if (stored.isEmpty()) return
@@ -1257,10 +1294,10 @@ object FrostSigningManager {
/**
* The key a room signs with, or null when it has none.
*
* Signing runs in the #admins room, which is not where the ceremony ran. A
* ceremony needs a NIP-17 group -- every member an equal admin, no MLS tree
* to be outside of -- while a group event needs an MLS one, so the two
* cannot be the same room.
* Signing usually runs in the #admins room, which is not where the ceremony
* ran. A ceremony needs a NIP-17 group -- every member an equal admin, no
* MLS tree 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 statement the
* room opened with and the group signed, naming the ceremony behind it. That
@@ -1280,11 +1317,13 @@ object FrostSigningManager {
* 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.
* signed before the room exists and filed as the room is created, so a
* device that missed that session -- a member invited later, a reinstall --
* has a room and no state, and the scan is what keeps it usable.
* `GroupKeyStateManager.propose` is the one caller that does not come
* through here at all, because it names the ceremony outright: the room it
* runs in is the NIP-17 room the ceremony was held in, which signs nothing
* else and has no key state of its own.
*/
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? {
GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state ->
@@ -1327,11 +1366,27 @@ object FrostSigningManager {
* 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.
* ### The room a group signs from before it has one
*
* One case cannot be self-checked, because there is nothing yet to check
* against: the NIP-17 room a ceremony ran in, signing the very statement
* that lets the group's Marmot room be created -- see
* `GroupKeyStateManager.propose`. A NIP-17 room's id is an aggregation of
* its members' keys, so it is not derived from anything and no path reaches
* it. What the group signs as there is the room it is about to make: the
* ceremony's key at the app's admin path.
*
* That is admitted only when the ceremony is *this room's own*, and the path
* is the constant rather than anything off the wire. Both inputs are read
* from this device's database, so a proposer still chooses nothing: naming
* some other ceremony this device holds a share for gets no path at all, and
* a session with no path signs as the bare threshold key, which is not an
* identity any room answers to.
*
* Null is not a failure. `completedKey` will find a key for a Marmot room
* that was never derived from it -- 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,
@@ -1347,11 +1402,17 @@ object FrostSigningManager {
SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
return candidates.firstOrNull { path ->
candidates.firstOrNull { path ->
runCatching {
SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId
}.getOrDefault(false)
}?.let { return it }
if (localChatRoom.chatRoom.mlsGroupState == null && key.chatRoomId == chatRoomId) {
return SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
}
return null
}
/**
@@ -1741,16 +1802,27 @@ object FrostSigningManager {
}
/**
* Queues a signing message as an unprocessed marmot inner event.
* `NotaryViewModel` picks it up, MLS-encrypts it and broadcasts it as a
* kind:445 for the room — the same path every other event in a Marmot group
* takes, which is why this needs no transport of its own.
* Queues one of this device's signing messages for the outbound pipeline,
* on whichever transport the room it is in has.
*
* No p-tags. A gift wrap is addressed and sealed once per recipient, so the
* ceremony has to name everybody on every message; a group event is
* encrypted to the group, and who is in it is the MLS tree's business rather
* than the message's. That also means the signer set genuinely comes from
* the ceremony rather than from whoever happened to be tagged.
* In a Marmot room it is an unprocessed inner event: `NotaryViewModel` picks
* it up, MLS-encrypts it and broadcasts it as a kind:445 for the room -- the
* same path every other event in a Marmot group takes.
*
* In a NIP-17 room it is a gift-wrap payload, sealed once per recipient and
* broadcast the way a ritual message is. That is not a second-class path; it
* is the only one a group has before it owns a Marmot room, which is exactly
* where the group's first signature is made -- see
* `GroupKeyStateManager.propose`.
*
* The p-tags are the difference between the two, and they are not
* cosmetic. A group event is encrypted to the group and who is in it is the
* MLS tree's business, so a Marmot message names nobody; a gift wrap is
* addressed and sealed per recipient, so a NIP-17 message has to name
* everybody or the members it left out never see it. Neither shape lets a
* recipient list decide anything: the signer set comes from the ceremony's
* host keys either way, so tagging somebody does not put them in it, and
* failing to tag somebody only stops them hearing.
*/
private suspend fun broadcast(
database: MantraDatabase,
@@ -1761,32 +1833,81 @@ object FrostSigningManager {
includeKey: Boolean = false,
signerIds: List<Int>? = null
) {
val tags = FrostSigningEvents.assembleTags(
val sessionTags = FrostSigningEvents.assembleTags(
sessionId = session.id,
dkgSessionId = if (includeKey) session.dkgSessionId else null,
signerIds = signerIds
)
// No MLS state is what marks a room NIP-17 -- the same reading
// `NostrNip17Dao.getOrCreateChatRoom` writes and `sendChatMessage` makes
// when it chooses between a group event and gift wraps.
val isMarmotRoom = localChatRoom.chatRoom.mlsGroupState != null
val tags = if (isMarmotRoom) {
sessionTags
} else {
recipientTags(localChatRoom, session.userPublicKey) + sessionTags
}
val createdAt = Clock.System.now().epochSeconds
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 = session.userPublicKey,
createdAt = createdAt,
// The rumor id the outbound pipeline will recompute from these same
// fields when it assembles the event to encrypt, on either transport.
val id = EventHasher.hashId(
pubKey = session.userPublicKey,
createdAt = createdAt,
tags = tags,
content = content,
kind = kind
)
if (isMarmotRoom) {
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = id,
publicKey = session.userPublicKey,
kind = kind,
createdAt = Instant.fromEpochSeconds(createdAt),
tags = tags,
content = content,
kind = kind
),
publicKey = session.userPublicKey,
kind = kind,
createdAt = Instant.fromEpochSeconds(createdAt),
tags = tags,
content = content,
chatRoomId = localChatRoom.chatRoom.id
chatRoomId = localChatRoom.chatRoom.id
)
)
)
} else {
database.giftWrapPayloadDao().upsert(
GiftWrapPayload(
id = id,
publicKey = session.userPublicKey,
kind = kind,
createdAt = Instant.fromEpochSeconds(createdAt),
tags = tags,
content = content,
chatRoomId = localChatRoom.chatRoom.id
)
)
}
}
/**
* One p-tag per member of a NIP-17 room, the sender excepted.
*
* The sender is left out rather than tagged because a gift wrap is sealed
* per recipient and this device already has its own copy -- `publishOwn`
* records it locally. Deduplicated by member, since `localParticipants` is a
* row list and a room can carry the same person twice.
*/
private fun recipientTags(
localChatRoom: LocalChatRoom,
senderPublicKey: HexKey
): Array<Array<String>> = localChatRoom.localParticipants
.distinctBy { it.participant.participantPublicKey }
.filter { it.participant.participantPublicKey != senderPublicKey }
.map { localParticipant ->
PTag.assemble(
localParticipant.participant.participantPublicKey,
localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) }
)
}
.toTypedArray()
}

View File

@@ -9,35 +9,56 @@ 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.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* Puts what key a room signs with to the group, and files what the group says.
*
* 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`.
* [propose] asks for it before the room exists; every device [record]s the state
* when the signing session behind it produces a signature, or [adopt]s it the
* moment the room it names comes into being. All three ends land on the same
* [GroupKeyState] row, which is what a signing request is resolved against --
* see `FrostSigningManager.completedKey`.
*
* ### 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.
* state that does not rederive the room it names 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
* A room's key state is 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.
* every device at once, authored by the group's own key.
*
* 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.
* ### Why it is signed before the room is created
*
* ```
* ceremony completes in the NIP-17 room
* coordinator --[ 30320 proposal over a 30326 ]-> the same NIP-17 room
* ...a quorum signs, on gift wraps...
* every device holds the signed 30326, naming a room nobody has made yet
* coordinator creates the #admins room --> adopt() files the state as it appears
* ```
*
* It used to be the new room's first application message, which meant the group
* created a room and only afterwards agreed what it signs with. Two things were
* wrong with that order. The room's founding fact was settled after the
* founding, so a session that never reached a quorum left a live room whose
* every member had to fall back to rederiving; and the members who had to sign
* it were exactly the ones the room had just been created to hold, so a member
* whose key package could not be found was excluded from a decision they held a
* share of.
*
* Signed first, the state is a precondition of the room rather than an
* afterthought: the group agrees what it signs with while it is still only a
* ceremony and a NIP-17 chat, and the room is created already knowing. That is
* what puts `FrostSigningManager` on two transports -- see [propose].
*/
object GroupKeyStateManager {
private const val TAG = "GroupKeyStateManager"
@@ -49,33 +70,37 @@ object GroupKeyStateManager {
database.groupKeyStateDao().getByChatRoomId(chatRoomId)
/**
* Asks the group to say what the freshly made room signs with.
* Asks the group to say what the room it is about to make signs with.
*
* 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.
* Called once, by the ceremony's coordinator, in the NIP-17 room the
* ceremony ran in -- before the #admins room exists. [localChatRoom] is
* therefore where the session *runs*, and is not usually the room the state
* is *about*: that one is derived here, from [key] at [path], because the
* room's id and the key it signs as are the same value.
*
* 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.
* No row is written. The state is not this device's to assert, and there is
* not yet a room to hang one on; it appears on every device together when
* the room is created and [adopt] files the event a quorum signed.
*
* 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.
* The session signs at the subject room's path, so what comes back is a
* state signed by the very key it names. A reader needs nothing but the
* event to check that -- not even the room, which is why this can be signed
* before the room exists at all.
*
* 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 proposed another -- a bug worth failing
* on rather than asking the group to sign.
* Refuses to propose from a room that is neither the subject nor the
* ceremony's own, because `FrostSigningManager.signingPath` will resolve no
* path for one and the group would sign as its bare threshold key -- an
* identity no room answers to. Refuses a state that fails
* [GroupKeyState.verifies] for the same class of reason: it would mean this
* device derived the subject room from one key and proposed another.
*
* [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.
* room, because no room here has a key state -- establishing one is exactly
* what this session exists to do.
*
* A session of one, always, and never batched with anything else. A batch is
* all-or-nothing, so it is only as available as its worst item -- and this is
* the statement every other session in the room is opened against. Bundling
* the statement every other session in the group is opened against. Bundling
* it with a dialect would make the room's ability to sign at all depend on
* that dialect's aggregation succeeding.
*/
@@ -87,9 +112,21 @@ object GroupKeyStateManager {
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
createdAt: Long = Clock.System.now().epochSeconds
): FrostSigningSession {
val chatRoomId = localChatRoom.chatRoom.id
val signingRoomId = localChatRoom.chatRoom.id
val thresholdPublicKey = key.thresholdPublicKey
?: throw IllegalStateException("Ceremony ${key.id} has produced no key for $chatRoomId to sign with")
?: throw IllegalStateException(
"Ceremony ${key.id} has produced no key for $signingRoomId to propose"
)
// The room this is about: the group's key walked to the admin path,
// which is that room's id. It need not exist yet, and in the flow this
// was written for it does not.
val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId) {
"Room $signingRoomId is neither $chatRoomId nor where ceremony ${key.id} was held, " +
"so it has no standing to propose a key state"
}
val state = GroupKeyState(
chatRoomId = chatRoomId,
@@ -104,7 +141,10 @@ object GroupKeyStateManager {
"Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}"
}
logger.i("Proposing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}")
logger.i(
"Proposing key ${state.thresholdPublicKey} for room $chatRoomId at " +
"${state.derivationPath}, from $signingRoomId"
)
return FrostSigningManager.proposeSigning(
database = database,
@@ -128,20 +168,97 @@ object GroupKeyStateManager {
* 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.
*
* A state about a room this device does not have is kept rather than filed.
* That is the normal case now and not an error: the group signs its state
* before creating the room it describes, so the event routinely lands
* minutes before the room does. [adopt] is the other end of that, and the
* signed event waits on file in the meantime.
*/
suspend fun record(
database: MantraDatabase,
chatRoomId: String,
innerEvent: Event
): GroupKeyState? =
stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) }
): GroupKeyState? {
val state = stateFrom(chatRoomId, innerEvent) ?: return null
if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) {
logger.i(
"The group signed a key state for ${state.chatRoomId}, which does not exist " +
"yet; holding it until it does"
)
return null
}
return database.groupKeyStateDao().replace(state)
}
/**
* Files the state the group has already signed for a room that has just come
* into existence, if there is one.
*
* The counterpart to [record], and the reason a room can be created already
* knowing what it signs with. Both ways into a room end here: the member who
* creates it, and the member who arrives on a Welcome -- who was in the
* ceremony and so holds the signed event too, because it was signed in the
* NIP-17 room they were already in rather than in the room they have just
* joined.
*
* Nothing is trusted that [stateFrom] would not trust. This only looks
* somewhere else for the event: at what the group has signed, rather than at
* what has just arrived. A member who was not in the ceremony holds no such
* event and gets nothing, which is correct -- they have no share either, and
* `FrostSigningManager.completedKey` rederives for them as it always did.
*
* Idempotent: `replace` keeps the newest state per room, so adopting twice
* settles on the same row.
*/
suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? {
val state = signedStateFor(database, chatRoomId) ?: return null
logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}")
return database.groupKeyStateDao().replace(state)
}
/**
* The newest state the group has signed for [chatRoomId], read off this
* device's stored signed events rather than off the [GroupKeyState] table.
*
* Answerable for a room that does not exist, which is the whole point of it:
* between the group agreeing its key state and somebody creating the room,
* this is the only thing that can say the agreement was reached. It is what
* the ritual screen gates the "create the room" button on, and what [adopt]
* files once there is a room to file it against.
*/
suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId)
/**
* The newest state for [chatRoomId] among some signed events, or null if
* none of them is one.
*
* Takes the events rather than fetching them so a caller watching them
* arrive can ask the same question of each emission -- which is how the
* ceremony screen knows the group has finished agreeing.
*/
fun stateAmong(signedEvents: List<GroupSignedEvent>, chatRoomId: String): GroupKeyState? =
signedEvents
.asSequence()
.filter { it.kind == GroupKeyStateEvent.KIND }
// Named rather than inferred. Every other reader may fall back to
// the room an event arrived in; this one is walking events from
// every room at once, so a state that names no room names nothing.
.filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId }
.mapNotNull { stateFrom(chatRoomId, it.toEvent()) }
.maxByOrNull { it.announcedAt }
/**
* The state an event amounts to, or null if it amounts to none.
*
* 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?
* it names 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
@@ -153,13 +270,27 @@ object GroupKeyStateManager {
* 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.
*
* ### Which room the state is about
*
* The one its own `d` tag names, not the one it arrived in. Those used to be
* required to agree and a mismatch was dropped, which was the right rule
* while a state was made in the room it described -- and is the wrong one
* now that a group signs its state before creating that room, so the two are
* ordinarily different by design.
*
* Nothing is given up by that. The check the drop was standing in for is
* still made, and made against the *named* room: the state has to rederive
* it, and the signature has to be by the key that rederivation reaches. A
* state can therefore only ever be about a room it derives, whatever room it
* turned up in, so nobody can point one room at another room's key by
* putting it through the wrong door.
*
* [chatRoomId] -- where it arrived -- is only the fallback for a state that
* names no room at all, and appears in the logs.
*/
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 announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
if (thresholdPublicKey == null) {
@@ -192,7 +323,7 @@ object GroupKeyStateManager {
}
val state = GroupKeyState(
chatRoomId = chatRoomId,
chatRoomId = announced,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
@@ -201,11 +332,11 @@ object GroupKeyStateManager {
)
// 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.
// is speaking. Only the truth rederives the room it names.
if (!state.verifies()) {
logger.w(
"Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " +
"${state.derivationPath}, which does not derive room $chatRoomId; dropping"
"${state.derivationPath}, which does not derive room $announced; dropping"
)
return null
}

View File

@@ -11,10 +11,14 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
/**
* The nostr kinds a FROST signing session is carried on.
*
* These are **rumor** kinds: they only ever exist inside a Marmot group event,
* MLS-encrypted to the group and then wrapped again under the group's exporter
* secret, so no relay sees them and the replaceable semantics normally implied
* by the 3xxxx range never apply.
* These are **rumor** kinds: they only ever exist inside an encryption a relay
* cannot open, so no relay sees them and the replaceable semantics normally
* implied by the 3xxxx range never apply. Almost always that is a Marmot group
* event -- MLS-encrypted to the group and then wrapped again under the group's
* exporter secret. The exception is a group's first session, which agrees what
* its #admins room will sign with *before* that room exists and so runs in the
* NIP-17 room its ceremony ran in, on gift wraps. See
* `GroupKeyStateManager.propose`.
*
* Who talks to whom, in order:
*
@@ -40,13 +44,17 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
* on kind by the same inbound path. Starting at 30320 leaves that family room to
* grow into.
*
* The DKG's 30310-30316 look like a clash and are not: those exist only inside
* NIP-17 gift wraps, and nothing reads a kind across both transports. It is
* worth knowing that the numbers already overlap there -- the DKG's proposal and
* host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its
* round-1 kind is 30312, alongside SubmissionEvent -- because that separation is
* an accident of routing rather than a decision, and the next family added
* should not rely on it.
* The DKG's 30310-30316 look like a clash and are not, but the reason is
* narrower than it used to be. The nip30303 kinds exist only inside Marmot inner
* events and the DKG's only inside NIP-17 gift wraps, so the numbers overlap
* where nothing reads across -- the DKG's proposal and host-key kinds sit on
* 30310 and 30311 alongside two nip30303 kinds, and its round-1 kind is 30312,
* alongside SubmissionEvent.
*
* These kinds are now the ones that *do* travel on both transports, which is
* exactly why they must not overlap with either family. They do not. But that
* separation was an accident of routing rather than a decision, and the next
* family added should not rely on it.
*/
object FrostSigningEvents {
/**

View File

@@ -14,17 +14,25 @@ import press.mantra.compose.nostr.frost.tags.FrostKeyTag
/**
* What key a Marmot room signs with, signed by the group whose key it is.
*
* 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
* Nobody announces this; the group *agrees* it, and does so before the room it
* is about exists. What the ceremony's coordinator puts into the NIP-17 room the
* ceremony ran in 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"
* coordinator --[ 30320 proposal over a 30326 ]-> the ceremony's room
* "shall we say our room will sign with K?"
* ...the session runs, on gift wraps...
* everyone holds the signed 30326 "the group says that room signs with K"
* ...somebody creates the room...
* everyone files it as the room appears GroupKeyStateManager.adopt
* ```
*
* A room *is* its key, so this can be signed before the room exists and checked
* without it: everything a reader needs is the event. See
* `GroupKeyStateManager` for why the order was turned around.
*
* 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.
*
@@ -61,23 +69,24 @@ import press.mantra.compose.nostr.frost.tags.FrostKeyTag
*
* ### Replaceable, by this app rather than by a relay
*
* 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.
* Like every kind in [FrostSigningEvents] this only ever lives inside an
* encryption a relay cannot open -- a Marmot group event, or the gift wrap the
* proposal for it travels in -- so no relay 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
* 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.
* first, because a rotated key no longer derives the room it names.
*/
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.
* Sits with the signing family in the 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
@@ -86,10 +95,12 @@ object GroupKeyStateEvent {
/**
* 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.
* The room id goes on as the `d` tag so the event is self-addressing, and
* that is now load-bearing rather than convenient: a state is signed in a
* room that is not the one it is about, so the `d` tag is the only thing
* that says which room it belongs to. What stops it naming a room it has no
* business naming is the derivation, not the envelope -- see
* `GroupKeyStateManager.stateFrom`.
*/
fun assembleTags(
chatRoomId: String,

View File

@@ -3,7 +3,9 @@ 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.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.DkgApprovalStep
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
@@ -44,14 +46,15 @@ interface DkgRepository {
)
/**
* Asks the room to sign a statement of which ceremony's key it signs with,
* as its first message.
* Asks the group to sign a statement of which ceremony's key its #admins
* room will sign with, before that room is created.
*
* 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.
* Called once by the ceremony's coordinator, in the NIP-17 room the ceremony
* ran in. 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 this
* room has no standing to propose one -- both of which mean there is nothing
* true to propose.
*/
suspend fun proposeGroupKeyState(
localChatRoom: LocalChatRoom,
@@ -59,6 +62,33 @@ interface DkgRepository {
session: DkgSession
): FrostSigningSession?
/**
* The signing sessions open in a room, newest first, with what each signs.
*
* Read by the ritual screen to follow the one session a NIP-17 room ever
* holds: the group agreeing its key state. The items travel with the session
* because "which session is this" is answered by the kind of the event it is
* signing, not by the row.
*/
fun observeSigningSessions(chatRoomId: String): Flow<List<LocalFrostSigningSession>>
/**
* The key state the group has signed for the #admins room this room's
* ceremony will make, whether or not that room exists yet.
*
* The gate on creating it. Null means the group has not finished agreeing
* what it will sign with, and a room created then would be one whose
* founding fact was never settled.
*
* A flow over the signed events rather than a read, because the moment it
* changes is the moment there is a room to make -- and the state it is
* about does not exist as a row until somebody makes one.
*/
fun observeSignedGroupKeyState(chatRoomId: String): Flow<GroupKeyState?>
/** Files the state the group signed for a room that now exists. */
suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
@@ -88,6 +118,15 @@ interface DkgRepository {
userPublicKey: HexKey,
session: DkgSession
): FrostSigningSession? = null
override fun observeSigningSessions(
chatRoomId: String
): Flow<List<LocalFrostSigningSession>> = flowOf(emptyList())
override fun observeSignedGroupKeyState(chatRoomId: String): Flow<GroupKeyState?> =
flowOf(null)
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
}
}
}

View File

@@ -56,6 +56,7 @@ import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.extensions.memberName
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.repository.ChatRepository
@@ -71,6 +72,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.view.model.DkgRitualViewModel
import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.FrostSigningSession
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -91,6 +93,11 @@ import mantra.composeapp.generated.resources.start_key_ceremony
import mantra.composeapp.generated.resources.the_ceremony_was_abandoned
import mantra.composeapp.generated.resources.the_group_can_hold_one_key_together_split_so
import mantra.composeapp.generated.resources.the_group_has_a_shared_key
import mantra.composeapp.generated.resources.the_group_has_agreed_what_the_admins_room
import mantra.composeapp.generated.resources.the_group_could_not_agree_what_the_admins
import mantra.composeapp.generated.resources.the_group_is_agreeing_what_the_admins_room
import mantra.composeapp.generated.resources.agree_the_groups_signing_key
import mantra.composeapp.generated.resources.before_the_room_exists_the_group_signs
import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs
import mantra.composeapp.generated.resources.try_again
import mantra.composeapp.generated.resources.your_share_of_it_is_on_this_device_only_your
@@ -285,6 +292,9 @@ fun DkgRitualScreen(
round2Participants = dkgRitualUIState.round2Participants,
isActionPending = isActionPending,
adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn,
keyStateSession = dkgRitualUIState.keyStateSession,
isKeyStateSigned = dkgRitualUIState.isKeyStateSigned,
onProposeKeyState = dkgRitualViewModel::proposeKeyState,
onCreateAdminGroup = {
dkgRitualViewModel.createAdminGroup(onNavigateToRoute)
}
@@ -331,6 +341,9 @@ private fun RitualProgress(
round2Participants: Set<HexKey>,
isActionPending: Boolean,
adminGroupBlockedOn: List<String>,
keyStateSession: FrostSigningSession?,
isKeyStateSigned: Boolean,
onProposeKeyState: () -> Unit,
onCreateAdminGroup: () -> Unit,
) {
val stage = session.stage
@@ -479,6 +492,49 @@ private fun RitualProgress(
style = MaterialTheme.typography.labelMedium
)
// What the group has to settle before there is a room: which
// ceremony's key the #admins room signs with. Signed first and
// filed as the room is created, so the room's founding fact is
// never something it has to go and ask about afterwards.
//
// Shown to every member, not only the coordinator, because every
// member has to sign it -- the request itself is a line in this
// chat, which is where they answer it.
val keyStateFailed = keyStateSession?.stage == FrostSigningStage.FAILED
when {
isKeyStateSigned -> Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = Decorative)
Text(
text = stringResource(Res.string.the_group_has_agreed_what_the_admins_room),
style = MaterialTheme.typography.labelMedium
)
}
keyStateFailed -> Text(
text = stringResource(Res.string.the_group_could_not_agree_what_the_admins),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
keyStateSession != null -> Text(
text = stringResource(
Res.string.the_group_is_agreeing_what_the_admins_room,
keyStateSession.threshold,
keyStateSession.participantCount
),
style = MaterialTheme.typography.labelMedium
)
else -> Text(
text = stringResource(Res.string.before_the_room_exists_the_group_signs),
style = MaterialTheme.typography.labelMedium
)
}
// Offered to whoever ran the ceremony. Any member's device could
// derive the same room id and create it, but one of them has to go
// first, and the coordinator is the member the group already watched
@@ -496,17 +552,43 @@ private fun RitualProgress(
)
}
Button(
onClick = { onCreateAdminGroup() },
enabled = !isActionPending,
modifier = Modifier.fillMaxWidth()
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Groups, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
Text(text = stringResource(Res.string.create_the_admins_group))
// One button, two steps, in the order they have to happen:
// ask the group to agree the key, then make the room. The
// second is not offered early, because a room made before
// the group agreed would be the old order back again.
if (isKeyStateSigned) {
Button(
onClick = { onCreateAdminGroup() },
enabled = !isActionPending,
modifier = Modifier.fillMaxWidth()
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Groups, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
Text(text = stringResource(Res.string.create_the_admins_group))
}
}
} else {
// Offered again after a failure, and only then: a
// retry has to be a *new* session, never the same one
// resumed, because that session's nonce seeds have
// already been published against an aggregate.
// `propose` mints a fresh one every time, so tapping
// this is the safe retry by construction.
Button(
onClick = { onProposeKeyState() },
enabled = !isActionPending && (keyStateSession == null || keyStateFailed),
modifier = Modifier.fillMaxWidth()
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Key, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
Text(text = stringResource(Res.string.agree_the_groups_signing_key))
}
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.extensions.toHex
@@ -31,10 +32,12 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.Dispatchers.Main
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.view.state.DkgRitualUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import fr.acinq.phoenix.data.ActiveWallet
@@ -71,6 +74,9 @@ class DkgRitualViewModel(
/** Watches the current session's messages; replaced whenever the session changes. */
private var messageObserver: Job? = null
/** Watches the room's signing sessions, which is where the key state is agreed. */
private var keyStateObserver: Job? = null
/**
* The quorum the ritual will generate a key for. Pre-filled with the same
* majority default the group-creation screen offers.
@@ -126,6 +132,8 @@ class DkgRitualViewModel(
.defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
.coerceIn(quorumRange())
observeKeyState()
dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session ->
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
@@ -165,6 +173,97 @@ class DkgRitualViewModel(
}
}
/**
* Follows the group agreeing what its #admins room will sign with.
*
* Nothing here watches a [GroupKeyState] row, because there is no room yet
* for one to belong to -- being able to follow this before the room exists
* is the point. What is watched instead is the session, for how the signing
* is going, and the signed event, for whether it finished.
*
* The room a ceremony runs in signs nothing else, so the newest session in
* it is the key state's -- but it is matched on the kind it carries anyway,
* so a room that one day signs something else does not confuse the two.
*/
private fun observeKeyState() {
keyStateObserver?.cancel()
keyStateObserver = viewModelScope.launch(Dispatchers.IO) {
// Two flows because they answer two questions off two tables. How
// the signing is going is the session row; whether it finished is
// the signed event, and only that second one can be asked at all
// before there is a room -- which is the whole point of asking it.
launch {
dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
dkgRitualUIState = loaded.copy(
keyStateSession = sessions.firstOrNull { local ->
local.items.any { item ->
runCatching {
Event.fromJson(item.unsignedEventJson).kind
}.getOrNull() == GroupKeyStateEvent.KIND
}
}?.session
)
}
}
launch {
dkgRepository.observeSignedGroupKeyState(chatRoomId).collect { state ->
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
}
}
}
}
/**
* Asks the group to sign what its #admins room will sign with.
*
* The step before the room exists, and the reason it can be created knowing
* its own key state rather than being told afterwards. Offered to the
* ceremony's coordinator, who is also the member who will create the room.
*
* Nothing here waits for the answer: the signing runs on arriving messages
* like everything else, and the screen follows it through [observeKeyState].
*/
fun proposeKeyState() {
if (isActionPending.value) return
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
if (session.thresholdPublicKey == null) return
// Nothing to ask for, and nobody to ask twice. A failed session is the
// one case a second proposal is right, and it has to be a new session
// rather than that one resumed -- see `FrostSigningManager`, which is
// why `propose` mints a fresh id every time.
if (loaded.isKeyStateSigned) return
loaded.keyStateSession?.let { if (it.stage != FrostSigningStage.FAILED) return }
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val signing = dkgRepository.proposeGroupKeyState(
localChatRoom = loaded.localChatRoom,
userPublicKey = activeUserPublicKey,
session = session
)
isActionPending.value = false
if (signing == null) {
logger.e("Failed to propose the key state for $chatRoomId")
dkgRitualUIState = DkgRitualUIState.Error(
"Couldn't ask the group to agree its signing key. Please try again."
)
}
// On success the session flow above delivers the new row; no need to
// set it here and risk racing the observer.
}
}
/** Opens a ritual, making this device its coordinator, and publishes the proposal. */
fun startRitual() {
if (isActionPending.value) return
@@ -246,6 +345,12 @@ class DkgRitualViewModel(
* room is addressable without being announced, and two members racing to create
* it produce the same id instead of two rival rooms -- which is why this returns
* early to the existing room rather than minting a second one.
*
* Only once the group has signed what the room signs with. That statement is
* agreed in the NIP-17 room the ceremony ran in, before there is a Marmot
* room to hold it -- see `GroupKeyStateManager.propose` -- so by here it is
* a fact the room is created *knowing*, rather than the first thing it has
* to go and ask about.
*/
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
if (isActionPending.value) return
@@ -254,6 +359,10 @@ class DkgRitualViewModel(
val session = loaded.session ?: return
val thresholdPublicKey = session.thresholdPublicKey ?: return
// The screen offers nothing until the group has signed, so this is a
// guard rather than a path anybody takes.
if (!loaded.isKeyStateSigned) return
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
@@ -378,44 +487,30 @@ class DkgRitualViewModel(
return@launch
}
// The room comes into existence already knowing what it signs with.
// The group agreed that before any of this ran, in the NIP-17 room
// the ceremony was held in, and this device has been holding the
// signed statement since -- the line above is simply the first
// moment there is a room row to file it against.
//
// Before the members are added rather than after, because nothing
// here goes on the wire: filing it is local, and doing it while the
// room is certain to exist beats doing it after a step that can
// partly fail.
//
// Every other member does the same on their Welcome, in NostrDao,
// off the same event. A member invited later holds no such event,
// and no share either, so they have nothing to pick wrongly between
// -- `FrostSigningManager.completedKey` rederives its way to the
// same key for them.
dkgRepository.adoptGroupKeyState(localChatRoom.chatRoom.id)
val notAdded = runCatching {
chatRepository.addMembers(localChatRoom = localChatRoom, peers = addable)
}.onFailure {
logger.e("Failed to add members to admin group $groupId", it)
}.getOrElse { addable.map { (publicKey, _) -> publicKey } }
// 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. 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 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
)
isActionPending.value = false
if (notAdded.isNotEmpty()) {
@@ -446,6 +541,7 @@ class DkgRitualViewModel(
override fun onCleared() {
messageObserver?.cancel()
keyStateObserver?.cancel()
super.onCleared()
}

View File

@@ -1,6 +1,8 @@
package press.mantra.compose.ui.view.state
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.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
@@ -42,6 +44,25 @@ sealed interface DkgRitualUIState {
* state a retry starts from.
*/
val adminGroupBlockedOn: List<String> = emptyList(),
/**
* The session in which the group is agreeing what its #admins room will
* sign with, once somebody has opened one.
*
* A ceremony's room holds exactly one of these and nothing else, because
* a NIP-17 room signs nothing else -- see `GroupKeyStateManager.propose`.
* Null before it is proposed, and it stays on the screen afterwards so
* the group can see how far the signing has got.
*/
val keyStateSession: FrostSigningSession? = null,
/**
* Whether the group has finished signing that statement.
*
* Read from the signed event rather than from the [GroupKeyState] table,
* because the room the state is about does not exist yet -- being able
* to say this before there is a room is the whole reason the state is
* signed first. It is what gates creating the room.
*/
val isKeyStateSigned: Boolean = false,
): DkgRitualUIState {
val hostKeyCount: Int get() = hostKeyParticipants.size
val round1Count: Int get() = round1Participants.size