feat: run a ChillDKG ritual over a NIP-17 group
Robust groups can now generate a FROST threshold key together. The
group's members are the participants, the room's creator is the
coordinator, and the whole protocol travels as gift-wrapped rumors on
the chat the group already has -- so there is no second transport to
build, operate or debug.
This is what the quorum has been reaching for since it was introduced.
Until now "t of n must approve" had no key to approve anything with;
ChillDKG produces one that no single member holds.
## Transport: seven rumor kinds (nostr/dkg/)
coordinator --[ 30310 proposal ]-> everyone
participant --[ 30311 host key ]-> everyone
participant --[ 30312 round 1 ]-> everyone ParticipantMsg1
coordinator --[ 30313 coord round 1 ]-> everyone CoordinatorMsg1
participant --[ 30314 round 2 ]-> everyone ParticipantMsg2
coordinator --[ 30315 certificate ]-> everyone CoordinatorMsg2
anyone --[ 30316 failure ]-> everyone abort + reason
These only ever exist inside a NIP-17 gift wrap, so no relay sees them
unencrypted and the replaceable semantics normally implied by the 3xxxx
range never apply -- which is why they can sit next to the app's other
private kinds (30300-30309) without meaning anything different.
Every message is addressed to the whole group, even the two the protocol
only needs the coordinator to read. NIP-17 wraps per recipient anyway,
ChillDKG treats the coordinator as untrusted by construction, and having
every member observe the ritual is what makes a progress UI possible
without a side channel.
`DkgSessionIdTag` is on every message: a group may abandon an attempt and
start another, and a straggler from the dead one must be dropped rather
than mixed into the live session. `DkgThresholdTag` rides the proposal so
every participant validates the same SessionParams -- disagreement on `t`
fails the session instead of quietly producing a weaker key.
## Persistence: inputs, not state (database/model/Dkg*, schema v2)
DkgSession deliberately stores no protocol state. Reading EncPedPop
confirms randomness enters the participant steps only through the passed
`random`/`auxRand` arguments (`simplSeed = taggedHash("encpedpop seed",
seed + random + encContext)`), so every ChillDkg step is a pure function
of inputs. Keeping the two 32-byte randoms plus the received messages is
therefore enough to recompute any intermediate state on demand, and the
opaque ParticipantState/CoordinatorState objects -- which have no
serialization API -- never need to be persisted at all.
That is not a micro-optimisation. A DKG cannot finish unless all n
members take part, and chat users close apps mid-round; recomputation is
what lets a ritual resume instead of forcing the group to start over.
DkgParticipantMessage is keyed (sessionId, participantPublicKey, kind) so
a redelivered message overwrites rather than accumulates -- relays
redeliver, and a duplicated round-1 message would hand the coordinator a
participant list of the wrong length.
Database goes to version 2 with an AutoMigration: v2 only adds tables, so
Room generates it. Schema 2.json is exported alongside.
## Driving it (managers/ChillDkgRitualManager.kt)
State machine driven entirely by arriving messages: persist, then ask
whether the ritual can move. Because every step is recomputable there is
no long-lived session in memory to lose, and processing is idempotent --
a redelivered message re-runs a step that has already been taken and
changes nothing.
The coordinator is a participant too, so it records its own outbound
messages locally: its round-1 message has to be in its own aggregation
alongside everyone else's. Being the room's creator buys it no authority
here -- ChillDKG's coordinator relays but cannot learn secrets or bias
the key -- only the job of aggregating.
Two decisions worth knowing:
* Host keys are DERIVED, not reused. `sha256("mantra/chilldkg/host-key/v1"
|| nostrSeckey)`. Reusing the nostr identity key directly was the
simpler option, but one secret serving two protocols means a flaw in
either reaches the other. Deriving from the same seed keeps it
recoverable from the wallet backup, which matters because ChillDKG
needs the host secret key to recover a session's outputs and asking
chat users to back up a second secret is how keys get lost.
* Participant order is a bytewise sort of the host public keys. ChillDKG
fails outright if participants disagree on ordering, and a sort is the
only order every device can derive independently from the same set.
Any ChillDkg exception ends the session for this device and is broadcast
as a 30316 so the rest of the group stops waiting, rather than leaving
every member on a spinner that will never resolve.
## Inbound (database/dao/NostrDao.kt)
One branch on the existing decrypted-gift-wrap dispatch, beside the
kind-14 and WelcomeEvent branches, handing ritual kinds to the manager.
## UI (ui/.../DkgRitualScreen.kt + view model, state, route)
Reached from chat room detail via "Shared Key", shown only for rooms with
no MLS state -- i.e. the NIP-17/robust ones. An MLS room has a single
admin and no group key to share, so the entry point would be a lie there.
The screen is a ladder of rounds with real counts ("3 of 5") rather than
a spinner. The unusual thing about a DKG, and the thing the UI has to get
across, is that it needs *everyone* at once; a count says who it is
waiting on, an indeterminate spinner says nothing. The coordinator gets
the start button, everyone else is told who they are waiting for, and a
failed ritual states plainly that no key was created and it is safe to
run again.
DkgSession.threshold finally gives the quorum somewhere to live. The
value chosen during group creation is still not persisted on ChatRoom,
so this screen re-asks with the same majority default rather than
inventing a different one; there is a TODO where that gap closes.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Not runtime-verified: exercising a DKG needs several devices exchanging
live messages, and the library's own vector suite needs JDK 21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package press.mantra.compose.database
|
||||
|
||||
import androidx.room3.ColumnTypeConverters
|
||||
import androidx.room3.AutoMigration
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.room3.immediateTransaction
|
||||
@@ -14,6 +15,7 @@ import press.mantra.compose.database.dao.ChatMessageDao
|
||||
import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao
|
||||
import press.mantra.compose.database.dao.ChatRoomDao
|
||||
import press.mantra.compose.database.dao.ConnectionDao
|
||||
import press.mantra.compose.database.dao.DkgSessionDao
|
||||
import press.mantra.compose.database.dao.GiftWrapMessageDao
|
||||
import press.mantra.compose.database.dao.GiftWrapPayloadDao
|
||||
import press.mantra.compose.database.dao.GiftWrapSealDao
|
||||
@@ -98,6 +100,8 @@ import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.QuotedRelation
|
||||
import press.mantra.compose.database.model.Reaction
|
||||
import press.mantra.compose.database.model.RecentSearch
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.Relay
|
||||
import press.mantra.compose.database.model.RepostedRelation
|
||||
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
|
||||
@@ -118,6 +122,8 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
ChatMessageBroadcastNostrEventReceiptRelation::class,
|
||||
ChatMessageNostrEventRelation::class,
|
||||
ChatRoom::class,
|
||||
DkgParticipantMessage::class,
|
||||
DkgSession::class,
|
||||
GiftWrapMessage::class,
|
||||
GiftWrapSeal::class,
|
||||
GiftWrapPayload::class,
|
||||
@@ -158,7 +164,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 1
|
||||
version = 2,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
AutoMigration(from = 1, to = 2)
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
abstract class MantraDatabase: RoomDatabase() {
|
||||
@@ -175,6 +186,8 @@ abstract class MantraDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun chatRoomDao(): ChatRoomDao
|
||||
|
||||
abstract fun dkgSessionDao(): DkgSessionDao
|
||||
|
||||
abstract fun connectionDao(): ConnectionDao
|
||||
|
||||
abstract fun giftWrapMessageDao(): GiftWrapMessageDao
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE id = :sessionId")
|
||||
suspend fun getSessionById(sessionId: String): DkgSession?
|
||||
|
||||
@Query("SELECT * FROM DkgSession WHERE id = :sessionId")
|
||||
fun observeSessionById(sessionId: String): Flow<DkgSession?>
|
||||
|
||||
/**
|
||||
* The ritual a room is currently running, newest first. A group may have
|
||||
* abandoned earlier attempts; the live one is the most recent.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?>
|
||||
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(dkgSession: DkgSession)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(dkgParticipantMessage: DkgParticipantMessage)
|
||||
|
||||
@Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY participantPublicKey ASC")
|
||||
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<DkgParticipantMessage>
|
||||
|
||||
@Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC")
|
||||
fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind")
|
||||
suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int
|
||||
|
||||
@Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind AND participantPublicKey = :participantPublicKey")
|
||||
suspend fun getMessage(sessionId: String, kind: Kind, participantPublicKey: HexKey): DkgParticipantMessage?
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import press.mantra.compose.exceptions.MarmotMissingNostrGroupDataExtension
|
||||
import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException
|
||||
import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.managers.MarmotInboundManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -919,6 +921,25 @@ abstract class NostrDao(
|
||||
logger.w("Chat Room already exists for $nostrGroupId")
|
||||
}
|
||||
// TODO: Check encoding...
|
||||
} else if (DkgRitualEvents.isDkgRitualKind(decryptedGiftWrapPayload.kind)) {
|
||||
// A ChillDKG ritual message for one of our NIP-17 groups.
|
||||
// The manager is idempotent, so a redelivered message
|
||||
// simply re-runs a step it has already taken.
|
||||
val localChatRoom = database.chatRoomDao().findChatRoomById(
|
||||
decryptedGiftWrapPayload.chatRoomId
|
||||
)
|
||||
|
||||
if (localChatRoom == null) {
|
||||
logger.w("DKG payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}")
|
||||
} else {
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = decryptedGiftWrapPayload,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex(),
|
||||
nostrPrivateKey = activeKeyPair.privKey!!
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.w("Unsupported event: $decryptedGiftWrapPayload")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One ritual message from one member, keyed so a duplicate delivery overwrites
|
||||
* rather than accumulates — relays redeliver, and feeding the same round-1
|
||||
* message in twice would make the coordinator's participant list the wrong
|
||||
* length.
|
||||
*
|
||||
* Covers the host key, round 1 and round 2; the coordinator's own broadcasts
|
||||
* live on [DkgSession] because there is only ever one of each.
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["sessionId", "participantPublicKey", "kind"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DkgSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("sessionId"),
|
||||
],
|
||||
)
|
||||
data class DkgParticipantMessage(
|
||||
val sessionId: String,
|
||||
|
||||
/** The member's nostr public key — who sent it. */
|
||||
val participantPublicKey: HexKey,
|
||||
|
||||
/** One of the `DkgRitualEvents` kinds. */
|
||||
val kind: Kind,
|
||||
|
||||
/** Hex of the protocol bytes, or of the host public key for a host-key message. */
|
||||
val payload: HexKey,
|
||||
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import press.mantra.compose.database.model.traits.LocalStoreEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One ChillDKG ritual, as this device sees it.
|
||||
*
|
||||
* Deliberately stores inputs rather than protocol state. `ChillDkg`'s
|
||||
* participant and coordinator steps are pure functions of their inputs — the
|
||||
* only randomness enters through the `random`/`auxRand` arguments — so keeping
|
||||
* [round1Random], [round2AuxRandom] and the received messages is enough to
|
||||
* recompute every intermediate state on demand. That is what lets a ritual
|
||||
* survive the app being killed halfway through, which matters because a DKG
|
||||
* needs every member to finish and members close apps.
|
||||
*
|
||||
* [round1Random] and [round2AuxRandom] are secret: they reconstruct this
|
||||
* device's share. They live here under the same "encrypted local storage"
|
||||
* assumption `MlsGroupState` already relies on.
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ChatRoom::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["chatRoomId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("chatRoomId"),
|
||||
],
|
||||
)
|
||||
data class DkgSession(
|
||||
/** Minted by the coordinator, carried on every message as `dkg_session`. */
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val chatRoomId: String,
|
||||
|
||||
/** The member running the ritual — by construction, the room's creator. */
|
||||
val coordinatorPublicKey: HexKey,
|
||||
|
||||
/** Whose device this row belongs to, for multi-account support. */
|
||||
val userPublicKey: HexKey,
|
||||
|
||||
/** The `t` of t-of-n, taken from the quorum chosen when the group was made. */
|
||||
val threshold: Int,
|
||||
|
||||
/** The `n`: how many members have to show up before params can be built. */
|
||||
val participantCount: Int,
|
||||
|
||||
val stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS,
|
||||
|
||||
/** This device's ChillDKG host public key (33-byte compressed, hex). */
|
||||
val hostPublicKey: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness for `participantStep1`. */
|
||||
val round1Random: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness for `participantStep2`. */
|
||||
val round2AuxRandom: HexKey,
|
||||
|
||||
/** `CoordinatorMsg1` once it arrives, hex. */
|
||||
val coordinatorRound1: HexKey? = null,
|
||||
|
||||
/** `CoordinatorMsg2` — the success certificate — once it arrives, hex. */
|
||||
val certificate: HexKey? = null,
|
||||
|
||||
/** Result: the group's FROST threshold public key, hex. */
|
||||
val thresholdPublicKey: HexKey? = null,
|
||||
|
||||
/** Result, secret: this device's FROST secret share, hex. */
|
||||
val secretShare: HexKey? = null,
|
||||
|
||||
/** Result: recovery data, to be backed up alongside the host key. */
|
||||
val recoveryData: HexKey? = null,
|
||||
|
||||
val failureReason: String? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
): TimestampedEntity, LocalStoreEntity {
|
||||
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package press.mantra.compose.database.model.types
|
||||
|
||||
/**
|
||||
* How far a ChillDKG ritual has got, from the point of view of the device
|
||||
* holding the row. Coordinator and participants move through the same ladder;
|
||||
* the coordinator simply has extra work to do at [COLLECTING_HOST_KEYS],
|
||||
* [COLLECTING_ROUND_1] and [COLLECTING_ROUND_2].
|
||||
*/
|
||||
enum class DkgRitualStage {
|
||||
/** Proposal seen; waiting on every member's host public key. */
|
||||
COLLECTING_HOST_KEYS,
|
||||
|
||||
/** Params are settled; waiting on every member's round-1 message. */
|
||||
COLLECTING_ROUND_1,
|
||||
|
||||
/** Coordinator's round-1 message is out; waiting on every member's round-2 signature. */
|
||||
COLLECTING_ROUND_2,
|
||||
|
||||
/** Certificate issued and verified. The key exists. */
|
||||
COMPLETE,
|
||||
|
||||
/** Abandoned. See `DkgSession.failureReason`. */
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
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
|
||||
|
||||
class DatabaseDkgRepository(
|
||||
private val database: MantraDatabase,
|
||||
private val scope: CoroutineScope
|
||||
): DkgRepository {
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> =
|
||||
database.dkgSessionDao().observeLatestSessionForChatRoom(chatRoomId)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>> =
|
||||
database.dkgSessionDao().observeMessages(sessionId)
|
||||
|
||||
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? =
|
||||
database.dkgSessionDao().getLatestSessionForChatRoom(chatRoomId)
|
||||
|
||||
override suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
): DkgSession? = try {
|
||||
ChillDkgRitualManager.proposeRitual(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey,
|
||||
threshold = threshold
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error proposing DKG ritual for ${localChatRoom.chatRoom.id}", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DatabaseDkgRepository"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
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.GiftWrapPayload
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import ac.cord.auxiliary.frost.dkg.chill.ChillDkg
|
||||
import co.touchlab.kermit.Logger
|
||||
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.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import fr.acinq.bitcoin.Crypto
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Runs a ChillDKG ritual over a NIP-17 group.
|
||||
*
|
||||
* The group is the participant set, the room's creator is the coordinator, and
|
||||
* every protocol message travels as a gift-wrapped rumor on the kinds in
|
||||
* [DkgRitualEvents] — the same sealing and broadcast pipeline chat messages
|
||||
* already use, so there is no second transport to operate.
|
||||
*
|
||||
* The coordinator is a participant too. ChillDKG treats the coordinator as
|
||||
* untrusted (it relays but cannot learn secrets or bias the key), so being the
|
||||
* room's creator buys it no authority here — only the job of aggregating.
|
||||
*
|
||||
* ### Why this is driven by arriving messages
|
||||
*
|
||||
* Every step is a pure function of inputs this device has already stored, so
|
||||
* there is no long-lived in-memory session to lose. Each inbound message is
|
||||
* persisted and then the ritual is asked whether it can move; if the app dies
|
||||
* mid-round it picks up exactly where it left off on the next message.
|
||||
*/
|
||||
object ChillDkgRitualManager {
|
||||
private const val TAG = "ChillDkgRitualManager"
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
/**
|
||||
* Domain separator for deriving the ChillDKG host key. The host key must NOT
|
||||
* be the nostr identity key: reusing one secret across two protocols means a
|
||||
* flaw in either can reach the other. Deriving it from the same seed keeps it
|
||||
* recoverable from the wallet backup without being the same key.
|
||||
*/
|
||||
private const val HOST_KEY_DERIVATION_TAG = "mantra/chilldkg/host-key/v1"
|
||||
|
||||
/**
|
||||
* This device's long-term ChillDKG host secret key.
|
||||
*
|
||||
* Deterministic in the nostr secret key, so it survives a reinstall from the
|
||||
* wallet seed alone — ChillDKG requires the host secret key to recover a
|
||||
* session's outputs, and asking a chat user to back up a second secret is a
|
||||
* good way to lose keys.
|
||||
*/
|
||||
fun deriveHostSecretKey(nostrPrivateKey: ByteArray): ByteArray =
|
||||
Crypto.sha256(HOST_KEY_DERIVATION_TAG.encodeToByteArray() + nostrPrivateKey)
|
||||
|
||||
fun deriveHostPublicKey(nostrPrivateKey: ByteArray): ByteArray =
|
||||
ChillDkg.hostpubkeyGen(deriveHostSecretKey(nostrPrivateKey))
|
||||
|
||||
/**
|
||||
* Opens a ritual. Only the room's creator should call this; every other
|
||||
* member joins by reacting to the proposal.
|
||||
*/
|
||||
suspend fun proposeRitual(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
): DkgSession {
|
||||
val sessionId = RandomInstance.bytes(32).toHex()
|
||||
val participantCount = localChatRoom.localParticipants.size
|
||||
|
||||
val session = DkgSession(
|
||||
id = sessionId,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
coordinatorPublicKey = userPublicKey,
|
||||
userPublicKey = userPublicKey,
|
||||
threshold = threshold,
|
||||
participantCount = participantCount,
|
||||
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).toHex(),
|
||||
// Fresh per session. Persisted because participantStep1/2 are pure in
|
||||
// it, which is what makes the whole ritual restart-safe.
|
||||
round1Random = RandomInstance.bytes(32).toHex(),
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex()
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
|
||||
logger.i("Proposing DKG ritual $sessionId: ${session.threshold}-of-$participantCount")
|
||||
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = DkgRitualEvents.PROPOSAL,
|
||||
content = "",
|
||||
includeThreshold = true
|
||||
)
|
||||
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds one inbound ritual message in and advances the ritual as far as it
|
||||
* will go. Safe to call twice with the same message: every write is keyed
|
||||
* and every step recomputed from stored inputs.
|
||||
*/
|
||||
suspend fun processRitualPayload(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
giftWrapPayload: GiftWrapPayload,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
val sessionId = DkgRitualEvents.parseSessionId(giftWrapPayload.tags)
|
||||
if (sessionId == null) {
|
||||
logger.w("DKG payload ${giftWrapPayload.id} has no session tag; dropping")
|
||||
return
|
||||
}
|
||||
|
||||
val session = when (giftWrapPayload.kind) {
|
||||
DkgRitualEvents.PROPOSAL -> acceptProposal(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = giftWrapPayload,
|
||||
sessionId = sessionId,
|
||||
userPublicKey = userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
else -> database.dkgSessionDao().getSessionById(sessionId)
|
||||
}
|
||||
|
||||
if (session == null) {
|
||||
// A message from a ritual this device never joined, or one it has
|
||||
// already torn down. Nothing sane to do with it.
|
||||
logger.w("No DKG session $sessionId for kind ${giftWrapPayload.kind}; dropping")
|
||||
return
|
||||
}
|
||||
|
||||
if (session.stage == DkgRitualStage.FAILED) {
|
||||
logger.i("Ritual $sessionId already failed; ignoring kind ${giftWrapPayload.kind}")
|
||||
return
|
||||
}
|
||||
|
||||
when (giftWrapPayload.kind) {
|
||||
DkgRitualEvents.PROPOSAL -> Unit // handled above; host key already published
|
||||
DkgRitualEvents.HOST_KEY,
|
||||
DkgRitualEvents.ROUND_1,
|
||||
DkgRitualEvents.ROUND_2 -> database.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = session.id,
|
||||
participantPublicKey = giftWrapPayload.publicKey,
|
||||
kind = giftWrapPayload.kind,
|
||||
payload = giftWrapPayload.content
|
||||
)
|
||||
)
|
||||
DkgRitualEvents.COORDINATOR_ROUND_1 -> database.dkgSessionDao().upsert(
|
||||
session.copy(coordinatorRound1 = giftWrapPayload.content, updatedAt = Clock.System.now())
|
||||
)
|
||||
DkgRitualEvents.CERTIFICATE -> database.dkgSessionDao().upsert(
|
||||
session.copy(certificate = giftWrapPayload.content, updatedAt = Clock.System.now())
|
||||
)
|
||||
DkgRitualEvents.FAILURE -> {
|
||||
fail(database, session, "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
advance(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = session.id,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a proposal and answers it with this device's host key. Returns the
|
||||
* session, whether it was just created or already known.
|
||||
*/
|
||||
private suspend fun acceptProposal(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
giftWrapPayload: GiftWrapPayload,
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray
|
||||
): DkgSession? {
|
||||
database.dkgSessionDao().getSessionById(sessionId)?.let { return it }
|
||||
|
||||
val threshold = DkgRitualEvents.parseThreshold(giftWrapPayload.tags)
|
||||
if (threshold == null) {
|
||||
logger.w("DKG proposal $sessionId carries no threshold; dropping")
|
||||
return null
|
||||
}
|
||||
|
||||
// Only the room's creator may open a ritual. Anyone else proposing one is
|
||||
// trying to run a key generation the group did not ask for.
|
||||
if (giftWrapPayload.publicKey != localChatRoom.chatRoom.userPublicKey &&
|
||||
giftWrapPayload.publicKey != coordinatorOf(localChatRoom)
|
||||
) {
|
||||
logger.w("DKG proposal $sessionId from non-coordinator ${giftWrapPayload.publicKey}; dropping")
|
||||
return null
|
||||
}
|
||||
|
||||
val session = DkgSession(
|
||||
id = sessionId,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
coordinatorPublicKey = giftWrapPayload.publicKey,
|
||||
userPublicKey = userPublicKey,
|
||||
threshold = threshold,
|
||||
participantCount = localChatRoom.localParticipants.size,
|
||||
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).toHex(),
|
||||
round1Random = RandomInstance.bytes(32).toHex(),
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex()
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
|
||||
logger.i("Joined DKG ritual $sessionId (${threshold}-of-${session.participantCount})")
|
||||
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes whatever step the stored messages now allow. Called after every
|
||||
* inbound message; a no-op until a round is actually complete.
|
||||
*/
|
||||
private suspend fun advance(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
val session = database.dkgSessionDao().getSessionById(sessionId) ?: return
|
||||
if (session.stage == DkgRitualStage.COMPLETE || session.stage == DkgRitualStage.FAILED) return
|
||||
|
||||
val hostSeckey = deriveHostSecretKey(nostrPrivateKey)
|
||||
|
||||
try {
|
||||
val params = sessionParams(database, session) ?: return
|
||||
|
||||
when (session.stage) {
|
||||
DkgRitualStage.COLLECTING_HOST_KEYS -> {
|
||||
// Every host key is in, so the params are settled and round 1
|
||||
// can be computed. Publish ours and move on.
|
||||
val (_, pmsg1) = ChillDkg.participantStep1(
|
||||
hostseckey = hostSeckey,
|
||||
params = params,
|
||||
random = session.round1Random.hexToByteArray()
|
||||
)
|
||||
val moved = session.copy(
|
||||
stage = DkgRitualStage.COLLECTING_ROUND_1,
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
database.dkgSessionDao().upsert(moved)
|
||||
publishOwn(database, localChatRoom, moved, DkgRitualEvents.ROUND_1, pmsg1.toHex())
|
||||
|
||||
advance(database, localChatRoom, sessionId, nostrPrivateKey)
|
||||
}
|
||||
|
||||
DkgRitualStage.COLLECTING_ROUND_1 -> {
|
||||
if (session.isCoordinator()) aggregateRound1(database, localChatRoom, session, params)
|
||||
|
||||
val coordinatorRound1 = database.dkgSessionDao()
|
||||
.getSessionById(sessionId)?.coordinatorRound1 ?: return
|
||||
|
||||
val (_, pmsg2) = ChillDkg.participantStep2(
|
||||
hostseckey = hostSeckey,
|
||||
state1 = ChillDkg.participantStep1(
|
||||
hostseckey = hostSeckey,
|
||||
params = params,
|
||||
random = session.round1Random.hexToByteArray()
|
||||
).first,
|
||||
cmsg1 = coordinatorRound1.hexToByteArray(),
|
||||
auxRand = session.round2AuxRandom.hexToByteArray()
|
||||
)
|
||||
|
||||
val moved = session.copy(
|
||||
stage = DkgRitualStage.COLLECTING_ROUND_2,
|
||||
coordinatorRound1 = coordinatorRound1,
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
database.dkgSessionDao().upsert(moved)
|
||||
publishOwn(database, localChatRoom, moved, DkgRitualEvents.ROUND_2, pmsg2.toHex())
|
||||
|
||||
advance(database, localChatRoom, sessionId, nostrPrivateKey)
|
||||
}
|
||||
|
||||
DkgRitualStage.COLLECTING_ROUND_2 -> {
|
||||
if (session.isCoordinator()) aggregateRound2(database, localChatRoom, session, params, hostSeckey)
|
||||
|
||||
val certificate = database.dkgSessionDao()
|
||||
.getSessionById(sessionId)?.certificate ?: return
|
||||
|
||||
finalize(database, session, params, hostSeckey, certificate)
|
||||
}
|
||||
|
||||
DkgRitualStage.COMPLETE, DkgRitualStage.FAILED -> Unit
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Any ChillDkg exception means the session is over for this device:
|
||||
// FaultyParticipantError / FaultyCoordinatorError name a culprit,
|
||||
// everything else is a local problem. Either way the key is not
|
||||
// usable, so say so rather than leaving a spinner running forever.
|
||||
logger.e("DKG ritual $sessionId failed", e)
|
||||
fail(database, session, e.message ?: e::class.simpleName ?: "Unknown error")
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = DkgRitualEvents.FAILURE,
|
||||
content = e.message ?: "Ritual failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Coordinator: fold every round-1 message into `CoordinatorMsg1` and publish it. */
|
||||
private suspend fun aggregateRound1(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
session: DkgSession,
|
||||
params: ChillDkg.SessionParams
|
||||
) {
|
||||
if (session.coordinatorRound1 != null) return
|
||||
|
||||
val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return
|
||||
|
||||
val (_, cmsg1) = ChillDkg.coordinatorStep1(pmsgs1 = pmsgs1, params = params)
|
||||
|
||||
database.dkgSessionDao().upsert(
|
||||
session.copy(coordinatorRound1 = cmsg1.toHex(), updatedAt = Clock.System.now())
|
||||
)
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = DkgRitualEvents.COORDINATOR_ROUND_1,
|
||||
content = cmsg1.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
/** Coordinator: fold every round-2 signature into the certificate and publish it. */
|
||||
private suspend fun aggregateRound2(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
session: DkgSession,
|
||||
params: ChillDkg.SessionParams,
|
||||
hostSeckey: ByteArray
|
||||
) {
|
||||
if (session.certificate != null) return
|
||||
|
||||
val cmsg1 = session.coordinatorRound1 ?: return
|
||||
val pmsgs2 = orderedPayloads(database, session, DkgRitualEvents.ROUND_2) ?: return
|
||||
|
||||
// Rebuild the coordinator state from the round-1 messages rather than
|
||||
// holding it across rounds — same inputs, same state, and it survives a
|
||||
// restart between the two aggregations.
|
||||
val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return
|
||||
val (coordinatorState, _) = ChillDkg.coordinatorStep1(pmsgs1 = pmsgs1, params = params)
|
||||
|
||||
val (cmsg2, _, _) = ChillDkg.coordinatorFinalize(state = coordinatorState, pmsgs2 = pmsgs2)
|
||||
|
||||
database.dkgSessionDao().upsert(
|
||||
session.copy(
|
||||
coordinatorRound1 = cmsg1,
|
||||
certificate = cmsg2.toHex(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
)
|
||||
broadcast(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = DkgRitualEvents.CERTIFICATE,
|
||||
content = cmsg2.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
/** Verify the certificate and store the share. This is the ritual's payoff. */
|
||||
private suspend fun finalize(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession,
|
||||
params: ChillDkg.SessionParams,
|
||||
hostSeckey: ByteArray,
|
||||
certificate: HexKey
|
||||
) {
|
||||
val (state1, _) = ChillDkg.participantStep1(
|
||||
hostseckey = hostSeckey,
|
||||
params = params,
|
||||
random = session.round1Random.hexToByteArray()
|
||||
)
|
||||
val (state2, _) = ChillDkg.participantStep2(
|
||||
hostseckey = hostSeckey,
|
||||
state1 = state1,
|
||||
cmsg1 = (session.coordinatorRound1 ?: return).hexToByteArray(),
|
||||
auxRand = session.round2AuxRandom.hexToByteArray()
|
||||
)
|
||||
|
||||
val (dkgOutput, recoveryData) = ChillDkg.participantFinalize(
|
||||
state2 = state2,
|
||||
cmsg2 = certificate.hexToByteArray()
|
||||
)
|
||||
|
||||
logger.i("DKG ritual ${session.id} complete")
|
||||
|
||||
database.dkgSessionDao().upsert(
|
||||
session.copy(
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
certificate = certificate,
|
||||
thresholdPublicKey = dkgOutput.threshPk.toHex(),
|
||||
secretShare = dkgOutput.secshare?.toHex(),
|
||||
recoveryData = recoveryData.toHex(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The agreed [ChillDkg.SessionParams], or null while host keys are still
|
||||
* outstanding. Ordering is a bytewise sort of the host public keys: ChillDKG
|
||||
* fails outright if the participants disagree on the order, and sorting is
|
||||
* the only ordering every device can derive independently.
|
||||
*/
|
||||
private suspend fun sessionParams(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession
|
||||
): ChillDkg.SessionParams? {
|
||||
val hostKeys = database.dkgSessionDao().getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
|
||||
|
||||
if (hostKeys.size < session.participantCount) {
|
||||
logger.d("Ritual ${session.id}: ${hostKeys.size}/${session.participantCount} host keys")
|
||||
return null
|
||||
}
|
||||
|
||||
return ChillDkg.SessionParams(
|
||||
hostpubkeys = hostKeys.map { it.payload }.sorted().map { it.hexToByteArray() },
|
||||
t = session.threshold
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Round messages in participant order — i.e. ordered by the sender's host key,
|
||||
* matching the [ChillDkg.SessionParams] ordering. Null while any are missing:
|
||||
* both coordinator steps require exactly `n` entries.
|
||||
*/
|
||||
private suspend fun orderedPayloads(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession,
|
||||
kind: Kind
|
||||
): List<ByteArray>? {
|
||||
val hostKeyByParticipant = database.dkgSessionDao()
|
||||
.getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
|
||||
.associate { it.participantPublicKey to it.payload }
|
||||
|
||||
val messages = database.dkgSessionDao().getMessagesByKind(session.id, kind)
|
||||
if (messages.size < session.participantCount) {
|
||||
logger.d("Ritual ${session.id}: ${messages.size}/${session.participantCount} of kind $kind")
|
||||
return null
|
||||
}
|
||||
|
||||
// Pair each message with its sender's host key so it can be sorted into
|
||||
// participant order. A message from someone who never published a host key
|
||||
// cannot be placed, and a short list would be rejected by ChillDkg anyway.
|
||||
val orderable = messages.mapNotNull { message ->
|
||||
hostKeyByParticipant[message.participantPublicKey]?.let { hostKey -> hostKey to message.payload }
|
||||
}
|
||||
if (orderable.size < session.participantCount) {
|
||||
logger.d("Ritual ${session.id}: ${orderable.size}/${session.participantCount} of kind $kind are placeable")
|
||||
return null
|
||||
}
|
||||
|
||||
return orderable
|
||||
.sortedBy { (hostKey, _) -> hostKey }
|
||||
.map { (_, payload) -> payload.hexToByteArray() }
|
||||
}
|
||||
|
||||
private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) {
|
||||
database.dkgSessionDao().upsert(
|
||||
session.copy(
|
||||
stage = DkgRitualStage.FAILED,
|
||||
failureReason = reason,
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publishHostKey(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
session: DkgSession
|
||||
) = publishOwn(database, localChatRoom, session, DkgRitualEvents.HOST_KEY, session.hostPublicKey)
|
||||
|
||||
/**
|
||||
* Broadcasts one of this device's own protocol messages AND records it
|
||||
* locally. The local copy matters: the coordinator is a participant too, and
|
||||
* its own message has to be in the aggregation alongside everyone else's.
|
||||
*/
|
||||
private suspend fun publishOwn(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
session: DkgSession,
|
||||
kind: Kind,
|
||||
content: String
|
||||
) {
|
||||
database.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = session.id,
|
||||
participantPublicKey = session.userPublicKey,
|
||||
kind = kind,
|
||||
payload = content
|
||||
)
|
||||
)
|
||||
broadcast(database, localChatRoom, session, kind, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a ritual message as a gift-wrap payload. `NotaryViewModel` picks it
|
||||
* up, seals a copy per participant and broadcasts — the same path chat
|
||||
* messages take, which is why this needs no transport of its own.
|
||||
*/
|
||||
private suspend fun broadcast(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
session: DkgSession,
|
||||
kind: Kind,
|
||||
content: String,
|
||||
includeThreshold: Boolean = false
|
||||
) {
|
||||
val receiverTags = localChatRoom.localParticipants
|
||||
.filter { it.participant.participantPublicKey != session.userPublicKey }
|
||||
.map { localParticipant ->
|
||||
PTag.assemble(
|
||||
localParticipant.participant.participantPublicKey,
|
||||
localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) }
|
||||
)
|
||||
}
|
||||
|
||||
val tags = receiverTags.toTypedArray() + DkgRitualEvents.assembleTags(
|
||||
sessionId = session.id,
|
||||
threshold = if (includeThreshold) session.threshold else null
|
||||
)
|
||||
|
||||
val createdAt = Clock.System.now().epochSeconds
|
||||
val giftWrapPayloadId = EventHasher.hashId(
|
||||
pubKey = session.userPublicKey,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
content = content,
|
||||
kind = kind
|
||||
)
|
||||
|
||||
database.giftWrapPayloadDao().upsert(
|
||||
GiftWrapPayload(
|
||||
id = giftWrapPayloadId,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
content = content,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
publicKey = session.userPublicKey
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** The room's creator, which is the ritual's coordinator by construction. */
|
||||
fun coordinatorOf(localChatRoom: LocalChatRoom): HexKey = localChatRoom.chatRoom.userPublicKey
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package press.mantra.compose.nostr.dkg
|
||||
|
||||
import press.mantra.compose.nostr.dkg.tags.DkgSessionIdTag
|
||||
import press.mantra.compose.nostr.dkg.tags.DkgThresholdTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
|
||||
/**
|
||||
* The nostr kinds a ChillDKG ritual is carried on.
|
||||
*
|
||||
* These are **rumor** kinds: they only ever exist inside a NIP-17 gift wrap
|
||||
* addressed to the group, so no relay sees them unencrypted and the replaceable
|
||||
* semantics normally implied by the 3xxxx range never apply. They sit next to
|
||||
* the app's other private kinds (30300-30309) deliberately — the transport,
|
||||
* sealing and broadcast pipeline is the same one chat messages ride.
|
||||
*
|
||||
* Who talks to whom, in order:
|
||||
*
|
||||
* ```
|
||||
* coordinator --[ 30310 proposal ]-> everyone "let's make a t-of-n key"
|
||||
* participant --[ 30311 host key ]-> everyone host pubkey for SessionParams
|
||||
* participant --[ 30312 round 1 ]-> everyone ChillDkg.ParticipantMsg1
|
||||
* coordinator --[ 30313 coord round 1 ]-> everyone ChillDkg.CoordinatorMsg1
|
||||
* participant --[ 30314 round 2 ]-> everyone ChillDkg.ParticipantMsg2
|
||||
* coordinator --[ 30315 certificate ]-> everyone ChillDkg.CoordinatorMsg2
|
||||
* anyone --[ 30316 failure ]-> everyone abort + blame
|
||||
* ```
|
||||
*
|
||||
* Everything is addressed to the whole group rather than point-to-point even
|
||||
* where the protocol only needs the coordinator to read it. NIP-17 wraps per
|
||||
* recipient anyway, the coordinator is untrusted by construction, and letting
|
||||
* every member watch the ritual is what makes the progress UI possible without
|
||||
* a second channel.
|
||||
*/
|
||||
object DkgRitualEvents {
|
||||
/** Coordinator opens a ritual. Threshold in [DkgThresholdTag]; membership is the wrap's p-tags. */
|
||||
val PROPOSAL: Kind = 30310
|
||||
|
||||
/** A participant's long-term ChillDKG host public key (33-byte compressed, hex). */
|
||||
val HOST_KEY: Kind = 30311
|
||||
|
||||
/** `ChillDkg.ParticipantMsg1` bytes, hex encoded. */
|
||||
val ROUND_1: Kind = 30312
|
||||
|
||||
/** `ChillDkg.CoordinatorMsg1` bytes, hex encoded. */
|
||||
val COORDINATOR_ROUND_1: Kind = 30313
|
||||
|
||||
/** `ChillDkg.ParticipantMsg2` bytes (a 64-byte signature), hex encoded. */
|
||||
val ROUND_2: Kind = 30314
|
||||
|
||||
/** `ChillDkg.CoordinatorMsg2` bytes (the success certificate), hex encoded. */
|
||||
val CERTIFICATE: Kind = 30315
|
||||
|
||||
/** Ritual abandoned. Content is the reason, for showing to the group. */
|
||||
val FAILURE: Kind = 30316
|
||||
|
||||
/** Every kind above, for filtering inbound payloads in one check. */
|
||||
val ALL: Set<Kind> = setOf(
|
||||
PROPOSAL,
|
||||
HOST_KEY,
|
||||
ROUND_1,
|
||||
COORDINATOR_ROUND_1,
|
||||
ROUND_2,
|
||||
CERTIFICATE,
|
||||
FAILURE
|
||||
)
|
||||
|
||||
fun isDkgRitualKind(kind: Kind): Boolean = kind in ALL
|
||||
|
||||
/**
|
||||
* Tags for a ritual message. The session id is on every kind so a late
|
||||
* message from an abandoned attempt can be dropped rather than mixed in.
|
||||
*/
|
||||
fun assembleTags(
|
||||
sessionId: String,
|
||||
threshold: Int? = null
|
||||
): Array<Array<String>> = buildList {
|
||||
add(DkgSessionIdTag.assemble(sessionId))
|
||||
threshold?.let { add(DkgThresholdTag.assemble(it)) }
|
||||
}.toTypedArray()
|
||||
|
||||
fun parseSessionId(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(DkgSessionIdTag::parse)?.sessionId
|
||||
|
||||
fun parseThreshold(tags: Array<Array<String>>): Int? =
|
||||
tags.firstNotNullOfOrNull(DkgThresholdTag::parse)?.threshold
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package press.mantra.compose.nostr.dkg.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Binds a DKG message to one ritual. A group can run a ChillDKG session more
|
||||
* than once — a member leaves, a threshold changes — and messages from an
|
||||
* abandoned attempt must never be fed into a live one.
|
||||
*/
|
||||
class DkgSessionIdTag(
|
||||
val sessionId: String,
|
||||
) {
|
||||
fun toTagArray() = assemble(sessionId = sessionId)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "dkg_session"
|
||||
|
||||
fun parse(tag: Array<String>): DkgSessionIdTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
return DkgSessionIdTag(sessionId = tag[1])
|
||||
}
|
||||
|
||||
fun assemble(sessionId: String): Array<String> = arrayOf(TAG_NAME, sessionId)
|
||||
|
||||
fun assemble(dkgSessionIdTag: DkgSessionIdTag) = assemble(sessionId = dkgSessionIdTag.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package press.mantra.compose.nostr.dkg.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `t` of the t-of-n the ritual is generating a key for. Carried on the
|
||||
* proposal so every participant validates the same [ChillDkg.SessionParams] —
|
||||
* disagreement on `t` makes the session fail rather than silently produce a
|
||||
* weaker key.
|
||||
*/
|
||||
class DkgThresholdTag(
|
||||
val threshold: Int,
|
||||
) {
|
||||
fun toTagArray() = assemble(threshold = threshold)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "dkg_threshold"
|
||||
|
||||
fun parse(tag: Array<String>): DkgThresholdTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
val threshold = tag[1].toIntOrNull() ?: return null
|
||||
|
||||
return DkgThresholdTag(threshold = threshold)
|
||||
}
|
||||
|
||||
fun assemble(threshold: Int): Array<String> = arrayOf(TAG_NAME, threshold.toString())
|
||||
|
||||
fun assemble(dkgThresholdTag: DkgThresholdTag) = assemble(threshold = dkgThresholdTag.threshold)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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.intermdiate.LocalChatRoom
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/** Reads and starts ChillDKG rituals. Advancing one is the inbound path's job. */
|
||||
interface DkgRepository {
|
||||
/** The room's current ritual — abandoned attempts are superseded by the newest. */
|
||||
fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?>
|
||||
|
||||
fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>>
|
||||
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/** Opens a ritual. Only meaningful for the room's creator. */
|
||||
suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
): DkgSession?
|
||||
|
||||
companion object {
|
||||
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
|
||||
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null
|
||||
|
||||
override suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
): DkgSession? = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
@@ -50,6 +51,7 @@ import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
|
||||
@@ -265,6 +267,35 @@ fun ChatRoomDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// A shared threshold key only means something where every
|
||||
// member is an equal admin, which is the NIP-17 (robust) case.
|
||||
// MLS rooms have one admin and no group key to share.
|
||||
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) {
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Key,
|
||||
contentDescription = "Shared key"
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(10.dp)
|
||||
)
|
||||
|
||||
Text("Shared Key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.DkgRitualViewModel
|
||||
import press.mantra.compose.ui.view.state.DkgRitualUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
|
||||
* chat, shown as a ladder of rounds so members can see it is moving and who it
|
||||
* is waiting on.
|
||||
*
|
||||
* A DKG needs *everyone* present, which is unusual for a chat feature and the
|
||||
* main thing this screen has to communicate — hence a count against every step
|
||||
* rather than an indeterminate spinner.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DkgRitualScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
) {
|
||||
val dkgRitualViewModel: DkgRitualViewModel = viewModel(
|
||||
factory = DkgRitualViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
initialDkgRitualUIState = initialDkgRitualUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
)
|
||||
|
||||
when (val dkgRitualUIState = dkgRitualViewModel.dkgRitualUIState) {
|
||||
is DkgRitualUIState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
is DkgRitualUIState.Loaded -> {
|
||||
val session = dkgRitualUIState.session
|
||||
val participantCount = dkgRitualUIState.participantCount
|
||||
val isCoordinator = dkgRitualViewModel.isCoordinator()
|
||||
val isActionPending = dkgRitualViewModel.isActionPending.value
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Shared key for ${dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"}",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Only the coordinator can open a ritual, and only when there
|
||||
// isn't one already running.
|
||||
if (isCoordinator && (session == null || session.stage == DkgRitualStage.FAILED)) {
|
||||
BottomAppBar(
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { dkgRitualViewModel.startRitual() }
|
||||
) {
|
||||
if (isActionPending) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.Key, contentDescription = "Start")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (session == null) "Start key ceremony" else "Try again"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 15.dp),
|
||||
text = "${dkgRitualViewModel.threshold.value} of $participantCount",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (session == null) {
|
||||
Text(
|
||||
text = "The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Everyone has to be online at the same time — the ceremony can only finish once all $participantCount of you have taken part.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
if (isCoordinator) {
|
||||
QuorumStepper(
|
||||
threshold = dkgRitualViewModel.threshold.value,
|
||||
participantCount = participantCount,
|
||||
quorumRange = dkgRitualViewModel.quorumRange(),
|
||||
onThresholdChange = dkgRitualViewModel::setThreshold
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Waiting for the group's creator to start it.",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RitualProgress(
|
||||
session = session,
|
||||
participantCount = participantCount,
|
||||
hostKeyCount = dkgRitualUIState.hostKeyCount,
|
||||
round1Count = dkgRitualUIState.round1Count,
|
||||
round2Count = dkgRitualUIState.round2Count
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DkgRitualUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "Shared key",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
LoadingDataIndicator(fillScreen = false)
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialDkgRitualUIState == DkgRitualUIState.Loading) {
|
||||
dkgRitualViewModel.initiate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The ladder. Each round names who it is still waiting on, by count. */
|
||||
@Composable
|
||||
private fun RitualProgress(
|
||||
session: DkgSession,
|
||||
participantCount: Int,
|
||||
hostKeyCount: Int,
|
||||
round1Count: Int,
|
||||
round2Count: Int,
|
||||
) {
|
||||
val stage = session.stage
|
||||
|
||||
if (stage == DkgRitualStage.FAILED) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(15.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.ErrorOutline, contentDescription = null)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("The ceremony was abandoned.", style = MaterialTheme.typography.titleSmall)
|
||||
session.failureReason?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
"Nothing was created and no key exists. It is safe to run it again.",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${session.threshold} of $participantCount members will be needed to sign with this key.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
RitualStep(
|
||||
title = "Everyone joins",
|
||||
detail = "Each member publishes the key they will take part with.",
|
||||
count = hostKeyCount,
|
||||
total = participantCount,
|
||||
isDone = stage != DkgRitualStage.COLLECTING_HOST_KEYS
|
||||
)
|
||||
|
||||
RitualStep(
|
||||
title = "Round one",
|
||||
detail = "Each member commits to their part of the key.",
|
||||
count = round1Count,
|
||||
total = participantCount,
|
||||
isDone = stage == DkgRitualStage.COLLECTING_ROUND_2 || stage == DkgRitualStage.COMPLETE
|
||||
)
|
||||
|
||||
RitualStep(
|
||||
title = "Round two",
|
||||
detail = "Each member confirms everyone else's part.",
|
||||
count = round2Count,
|
||||
total = participantCount,
|
||||
isDone = stage == DkgRitualStage.COMPLETE
|
||||
)
|
||||
|
||||
if (stage == DkgRitualStage.COMPLETE) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(15.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
Text("The group has a shared key.", style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
|
||||
session.thresholdPublicKey?.let { thresholdPublicKey ->
|
||||
Text(
|
||||
text = "Key: ${thresholdPublicKey.take(16)}…",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RitualStep(
|
||||
title: String,
|
||||
detail: String,
|
||||
count: Int,
|
||||
total: Int,
|
||||
isDone: Boolean,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(15.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isDone) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null
|
||||
)
|
||||
Text(text = title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "$count of $total",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { if (total == 0) 0f else count.toFloat() / total.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(text = detail, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same shape as the group-creation quorum picker, so the choice reads the same. */
|
||||
@Composable
|
||||
private fun QuorumStepper(
|
||||
threshold: Int,
|
||||
participantCount: Int,
|
||||
quorumRange: IntRange,
|
||||
onThresholdChange: (Int) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "How many members will it take to sign?",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = { onThresholdChange(threshold - 1) },
|
||||
enabled = threshold > quorumRange.first
|
||||
) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "Fewer signers")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "$threshold of $participantCount",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
FilledIconButton(
|
||||
onClick = { onThresholdChange(threshold + 1) },
|
||||
enabled = threshold < quorumRange.last
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "More signers")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "This is fixed once the ceremony runs. Changing it later means generating a new key.",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun DkgRitualScreenPreview() {
|
||||
TorchTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
DkgRitualScreen(
|
||||
activeUserPublicKey = "",
|
||||
chatRoomId = "chatRoomId",
|
||||
initialDkgRitualUIState = DkgRitualUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "",
|
||||
userPublicKey = "",
|
||||
subject = "Group Discussions",
|
||||
description = "See something... say something.",
|
||||
initialGiftWrapPayloadId = null,
|
||||
mlsGroupState = null
|
||||
),
|
||||
)
|
||||
),
|
||||
activeWalletStateFlow = MutableStateFlow(null),
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import press.mantra.compose.MantraGlobal
|
||||
import press.mantra.compose.database.repository.DatabaseChatRepository
|
||||
import press.mantra.compose.database.repository.DatabaseDkgRepository
|
||||
import press.mantra.compose.database.repository.DatabaseMarmotRepository
|
||||
import press.mantra.compose.database.repository.DatabaseNostrRepository
|
||||
import press.mantra.compose.database.repository.DatabaseSearchRepository
|
||||
@@ -25,6 +26,7 @@ import press.mantra.compose.ui.composable.ChatRoomCreationScreen
|
||||
import press.mantra.compose.ui.composable.ChatRoomDetailScreen
|
||||
import press.mantra.compose.ui.composable.ChatRoomMessagingScreen
|
||||
import press.mantra.compose.ui.composable.CreateProfileScreen
|
||||
import press.mantra.compose.ui.composable.DkgRitualScreen
|
||||
import press.mantra.compose.ui.composable.HomeScreen
|
||||
import press.mantra.compose.ui.composable.ImplementationPendingScreen
|
||||
import press.mantra.compose.ui.composable.KeyPackageManagementScreen
|
||||
@@ -54,6 +56,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomCreationRout
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.CreateProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.KeyPackageManagementRoute
|
||||
@@ -146,6 +149,13 @@ fun MantraNavHost(
|
||||
)
|
||||
}
|
||||
|
||||
val databaseDkgRepository = remember {
|
||||
DatabaseDkgRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
applicationIOScope
|
||||
)
|
||||
}
|
||||
|
||||
val databaseMarmotRepository = remember {
|
||||
DatabaseMarmotRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
@@ -434,6 +444,17 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<DkgRitualRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DkgRitualRoute>()
|
||||
|
||||
DkgRitualScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository
|
||||
)
|
||||
}
|
||||
composable<SelectChatRoomTypeRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<SelectChatRoomTypeRoute>()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** The shared-key ceremony for one NIP-17 group. */
|
||||
@Serializable
|
||||
data class DkgRitualRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String
|
||||
): Route()
|
||||
@@ -0,0 +1,180 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
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.HexKey
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import fr.acinq.phoenix.managers.nostrPrivateKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Watches a group's ChillDKG ritual and, for the coordinator, starts one.
|
||||
*
|
||||
* Nothing here drives the protocol — the ritual advances in
|
||||
* [ChillDkgRitualManager] as messages arrive on the inbound path, so this only
|
||||
* has to read the session and show it moving.
|
||||
*/
|
||||
class DkgRitualViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
initialDkgRitualUIState: DkgRitualUIState,
|
||||
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
val chatRepository: ChatRepository,
|
||||
val dkgRepository: DkgRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var dkgRitualUIState: DkgRitualUIState by mutableStateOf(initialDkgRitualUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/** Watches the current session's messages; replaced whenever the session changes. */
|
||||
private var messageObserver: Job? = null
|
||||
|
||||
/**
|
||||
* The quorum the ritual will generate a key for. Pre-filled with the same
|
||||
* majority default the group-creation screen offers.
|
||||
*
|
||||
* TODO: the quorum chosen at creation time is not persisted on ChatRoom, so
|
||||
* it cannot be read back here. Storing it there would let this default to
|
||||
* what the group was actually promised rather than re-deriving it.
|
||||
*/
|
||||
val threshold: MutableState<Int> = mutableStateOf(ChatRoomType.MINIMUM_QUORUM)
|
||||
|
||||
fun isCoordinator(): Boolean {
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false
|
||||
|
||||
return ChillDkgRitualManager.coordinatorOf(loaded.localChatRoom) == activeUserPublicKey
|
||||
}
|
||||
|
||||
fun quorumRange(): IntRange {
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM)
|
||||
|
||||
return ChatRoomType.quorumRange(loaded.participantCount)
|
||||
}
|
||||
|
||||
fun setThreshold(value: Int) {
|
||||
if (isActionPending.value) return
|
||||
|
||||
threshold.value = value.coerceIn(quorumRange())
|
||||
}
|
||||
|
||||
fun initiate() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
if (localChatRoom == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't load this chat")
|
||||
return@launch
|
||||
}
|
||||
|
||||
dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
|
||||
threshold.value = ChatRoomType.defaultQuorum(localChatRoom.localParticipants.size)
|
||||
|
||||
dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session ->
|
||||
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
|
||||
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
|
||||
|
||||
dkgRitualUIState = loaded.copy(session = session)
|
||||
|
||||
// Re-point the message watcher at whatever session is current. An
|
||||
// abandoned ritual's counts must not keep ticking over the new one.
|
||||
messageObserver?.cancel()
|
||||
if (session != null) {
|
||||
messageObserver = viewModelScope.launch(Dispatchers.IO) {
|
||||
dkgRepository.observeMessages(session.id).collect { messages ->
|
||||
val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
|
||||
|
||||
dkgRitualUIState = current.copy(
|
||||
hostKeyCount = messages.count { it.kind == DkgRitualEvents.HOST_KEY },
|
||||
round1Count = messages.count { it.kind == DkgRitualEvents.ROUND_1 },
|
||||
round2Count = messages.count { it.kind == DkgRitualEvents.ROUND_2 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Coordinator only: opens the ritual and publishes the proposal. */
|
||||
fun startRitual() {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
|
||||
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
|
||||
if (nostrPrivateKey == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val session = dkgRepository.proposeRitual(
|
||||
localChatRoom = loaded.localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey.value.toByteArray(),
|
||||
threshold = threshold.value
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
if (session == null) {
|
||||
logger.e("Failed to start DKG ritual for $chatRoomId")
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
|
||||
}
|
||||
// On success the session flow above delivers the new row; no need to
|
||||
// set it here and risk racing the observer.
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
messageObserver?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DkgRitualViewModel"
|
||||
|
||||
fun factory(
|
||||
chatRoomId: String,
|
||||
activeUserPublicKey: HexKey,
|
||||
initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
DkgRitualViewModel(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
initialDkgRitualUIState = initialDkgRitualUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface DkgRitualUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
/** Null until somebody opens a ritual. */
|
||||
val session: DkgSession? = null,
|
||||
/** How many members have published each round's message, for the progress ladder. */
|
||||
val hostKeyCount: Int = 0,
|
||||
val round1Count: Int = 0,
|
||||
val round2Count: Int = 0,
|
||||
): DkgRitualUIState {
|
||||
val participantCount: Int get() = session?.participantCount ?: localChatRoom.localParticipants.size
|
||||
}
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): DkgRitualUIState
|
||||
|
||||
data object Loading: DkgRitualUIState
|
||||
}
|
||||
Reference in New Issue
Block a user