feat: run the ritual on bitcoin-kmp's native ChillDKG

Replaces `ac.cord.auxiliary.frost.dkg.chill.ChillDkg` with
`fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG` throughout ChillDkgRitualManager.

The implementation being dropped says what it is in its own header:

    WARNING: This code is slow and not hardened against side channel attacks. Do
    not use for anything but tests.

It is a 1090-line "Reference port of ChillDKG (chilldkg_ref/chilldkg.py)" doing
BigInteger arithmetic through ac.cord.auxiliary.cryptography's Scalar and
GroupElement, with no call into libsecp256k1 anywhere in the file.
secp256k1-frost-kmp's own KNOWN_ISSUES.md confirms the constant-time
libsecp256k1 backing is used only for hashing. Real key material was being
generated by it.

The replacement is 438 lines in which every operation delegates to
`Secp256k1.chilldkg*` -- the constant-time C from the secp256k1-zkp fork the
submodule chain compiles. This is an improvement, not a clean bill of health: that
module carries its own "experimental and must not be used in production" warning.
It is constant-time C instead of variable-time Kotlin, which is the part that
mattered.

Three things changed shape rather than just types.

SessionParams no longer exists. ChillDKG takes the host public keys and the
threshold at every call and hashes them into the session identity itself, so
`sessionParams()` becomes `hostPublicKeys()` returning List<PublicKey>, and the
threshold rides along on the session row. That removed a parameter from four
signatures.

Faults are values now, not exceptions. ChillDKG reports a faulty participant as a
ChilldkgFault field on each result because it is a normal outcome of a DKG rather
than a bug. This ritual has exactly one response to all of them -- the key is
unusable, so the session dies and the group is told -- so `raiseIfFaulty` turns
them into an exception and lets them join advance()'s existing single failure
path. The gain is in the failure text: the reason shown to the group goes from
whatever `e.message` happened to hold to "ChillDKG round 2 failed: a participant
is faulty (participant 3)". On a failed DKG, which participant to blame is the
only actionable thing there is.

Two recomputes got names. The old code inlined a second participantStep1 call to
rebuild state1 for participantStep2, and rebuilt coordinator state separately in
aggregateRound2. Those are now `participantState1()` and `coordinatorStep1()`, the
latter carrying the fault check so both of its callers get it. The
recompute-rather-than-store design is kept deliberately: ChillDKG's states are
serializable and could be persisted, but they are pure functions of inputs the
DkgSession row already holds, so storing them would mean a schema change and a
Room migration for no behavioural gain. That reasoning is now in the kdoc.

Also drops an unused hostSeckey parameter from aggregateRound2, whose signature
was changing anyway, and updates doc comments in DkgRitualEvents, DkgSession and
DkgThresholdTag that named types which no longer exist -- ChillDkg.ParticipantMsg1
and friends -- to the protocol's own names: pmsg1, cmsg1, CertEq signature,
certificate.

Compiles clean, but no ritual has been run on a device. The natives are in the APK
as of the previous commit; the first real ritual is the actual test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-04 23:25:46 +02:00
parent eee8fa0fd8
commit c9b6bd7992
4 changed files with 152 additions and 79 deletions

View File

@@ -14,7 +14,7 @@ import kotlin.time.Instant
/**
* One ChillDKG ritual, as this device sees it.
*
* Deliberately stores inputs rather than protocol state. `ChillDkg`'s
* 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

View File

@@ -8,7 +8,6 @@ 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
@@ -17,8 +16,14 @@ 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.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.ByteVector64
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG
import fr.acinq.secp256k1.ChilldkgFault
import kotlin.time.Clock
import kotlin.time.Instant
@@ -62,11 +67,11 @@ object ChillDkgRitualManager {
* 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 deriveHostSecretKey(nostrPrivateKey: ByteArray): PrivateKey =
PrivateKey(Crypto.sha256(HOST_KEY_DERIVATION_TAG.encodeToByteArray() + nostrPrivateKey))
fun deriveHostPublicKey(nostrPrivateKey: ByteArray): ByteArray =
ChillDkg.hostpubkeyGen(deriveHostSecretKey(nostrPrivateKey))
fun deriveHostPublicKey(nostrPrivateKey: ByteArray): PublicKey =
ChillDKG.hostPublicKey(deriveHostSecretKey(nostrPrivateKey))
/**
* Opens a ritual. Only the room's creator should call this; every other
@@ -89,7 +94,7 @@ object ChillDkgRitualManager {
userPublicKey = userPublicKey,
threshold = threshold,
participantCount = participantCount,
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).toHex(),
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.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(),
@@ -223,7 +228,7 @@ object ChillDkgRitualManager {
userPublicKey = userPublicKey,
threshold = threshold,
participantCount = localChatRoom.localParticipants.size,
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).toHex(),
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(),
round1Random = RandomInstance.bytes(32).toHex(),
round2AuxRandom = RandomInstance.bytes(32).toHex()
)
@@ -252,17 +257,18 @@ object ChillDkgRitualManager {
val hostSeckey = deriveHostSecretKey(nostrPrivateKey)
try {
val params = sessionParams(database, session) ?: return
val hostPublicKeys = hostPublicKeys(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()
)
// Every host key is in, so the participant set is settled and
// round 1 can be computed. Publish ours and move on.
val pmsg1 = ChillDKG.participantStep1(
hostSecretKey = hostSeckey,
hostPublicKeys = hostPublicKeys,
threshold = session.threshold,
random = ByteVector32(session.round1Random)
).message
val moved = session.copy(
stage = DkgRitualStage.COLLECTING_ROUND_1,
updatedAt = Clock.System.now()
@@ -274,21 +280,19 @@ object ChillDkgRitualManager {
}
DkgRitualStage.COLLECTING_ROUND_1 -> {
if (session.isCoordinator()) aggregateRound1(database, localChatRoom, session, params)
if (session.isCoordinator()) aggregateRound1(database, localChatRoom, session, hostPublicKeys)
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 step2 = ChillDKG.participantStep2(
hostSecretKey = hostSeckey,
state = participantState1(hostSeckey, hostPublicKeys, session),
coordinatorMessage = ByteVector(coordinatorRound1.hexToByteArray()),
auxRand = ByteVector32(session.round2AuxRandom)
)
step2.fault.raiseIfFaulty("round 2")
val pmsg2 = step2.certEqSignature
val moved = session.copy(
stage = DkgRitualStage.COLLECTING_ROUND_2,
@@ -302,21 +306,22 @@ object ChillDkgRitualManager {
}
DkgRitualStage.COLLECTING_ROUND_2 -> {
if (session.isCoordinator()) aggregateRound2(database, localChatRoom, session, params, hostSeckey)
if (session.isCoordinator()) aggregateRound2(database, localChatRoom, session, hostPublicKeys)
val certificate = database.dkgSessionDao()
.getSessionById(sessionId)?.certificate ?: return
finalize(database, session, params, hostSeckey, certificate)
finalize(database, session, hostPublicKeys, 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.
// The session is over for this device. Either a protocol fault came
// back from a ChillDKG step -- [raiseIfFaulty] turns those into
// exceptions naming the culprit -- or a step rejected a local input.
// 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(
@@ -334,13 +339,14 @@ object ChillDkgRitualManager {
database: MantraDatabase,
localChatRoom: LocalChatRoom,
session: DkgSession,
params: ChillDkg.SessionParams
hostPublicKeys: List<PublicKey>
) {
if (session.coordinatorRound1 != null) return
val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return
val (_, cmsg1) = ChillDkg.coordinatorStep1(pmsgs1 = pmsgs1, params = params)
val step1 = coordinatorStep1(pmsgs1, hostPublicKeys, session)
val cmsg1 = step1.message
database.dkgSessionDao().upsert(
session.copy(coordinatorRound1 = cmsg1.toHex(), updatedAt = Clock.System.now())
@@ -359,8 +365,7 @@ object ChillDkgRitualManager {
database: MantraDatabase,
localChatRoom: LocalChatRoom,
session: DkgSession,
params: ChillDkg.SessionParams,
hostSeckey: ByteArray
hostPublicKeys: List<PublicKey>
) {
if (session.certificate != null) return
@@ -371,9 +376,15 @@ object ChillDkgRitualManager {
// 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 coordinatorState = coordinatorStep1(pmsgs1, hostPublicKeys, session).state
val (cmsg2, _, _) = ChillDkg.coordinatorFinalize(state = coordinatorState, pmsgs2 = pmsgs2)
val certified = ChillDKG.coordinatorFinalize(
state = coordinatorState,
certEqSignatures = pmsgs2.map { ByteVector64(it) },
threshold = session.threshold
)
certified.fault.raiseIfFaulty("certificate")
val cmsg2 = certified.certificate
database.dkgSessionDao().upsert(
session.copy(
@@ -395,26 +406,25 @@ object ChillDkgRitualManager {
private suspend fun finalize(
database: MantraDatabase,
session: DkgSession,
params: ChillDkg.SessionParams,
hostSeckey: ByteArray,
hostPublicKeys: List<PublicKey>,
hostSeckey: PrivateKey,
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 step2 = ChillDKG.participantStep2(
hostSecretKey = hostSeckey,
state = participantState1(hostSeckey, hostPublicKeys, session),
coordinatorMessage = ByteVector((session.coordinatorRound1 ?: return).hexToByteArray()),
auxRand = ByteVector32(session.round2AuxRandom)
)
step2.fault.raiseIfFaulty("round 2")
val (dkgOutput, recoveryData) = ChillDkg.participantFinalize(
state2 = state2,
cmsg2 = certificate.hexToByteArray()
val output = ChillDKG.participantFinalize(
state = step2.state,
certificate = ByteVector(certificate.hexToByteArray()),
nParticipants = session.participantCount,
threshold = session.threshold
)
output.fault.raiseIfFaulty("finalize")
logger.i("DKG ritual ${session.id} complete")
@@ -422,24 +432,27 @@ object ChillDkgRitualManager {
session.copy(
stage = DkgRitualStage.COMPLETE,
certificate = certificate,
thresholdPublicKey = dkgOutput.threshPk.toHex(),
secretShare = dkgOutput.secshare?.toHex(),
recoveryData = recoveryData.toHex(),
thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(),
secretShare = output.secretShare?.value?.toHex(),
recoveryData = output.recovery?.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.
* The agreed participant set, 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.
*
* ChillDKG has no session-params object: every step takes this list and the
* threshold, and hashes them into the session identity itself.
*/
private suspend fun sessionParams(
private suspend fun hostPublicKeys(
database: MantraDatabase,
session: DkgSession
): ChillDkg.SessionParams? {
): List<PublicKey>? {
val hostKeys = database.dkgSessionDao().getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
if (hostKeys.size < session.participantCount) {
@@ -447,16 +460,75 @@ object ChillDkgRitualManager {
return null
}
return ChillDkg.SessionParams(
hostpubkeys = hostKeys.map { it.payload }.sorted().map { it.hexToByteArray() },
t = session.threshold
)
return hostKeys.map { it.payload }.sorted().map { PublicKey(ByteVector(it.hexToByteArray())) }
}
/**
* This device's round-1 state, recomputed rather than stored.
*
* [ChillDKG.ParticipantState1] is serializable, so this could be persisted —
* but it is a pure function of the host key, the participant set and
* [DkgSession.round1Random], all of which are already stored, so recomputing
* keeps the ritual restart-safe without adding protocol state to the schema.
*/
private fun participantState1(
hostSeckey: PrivateKey,
hostPublicKeys: List<PublicKey>,
session: DkgSession
) = ChillDKG.participantStep1(
hostSecretKey = hostSeckey,
hostPublicKeys = hostPublicKeys,
threshold = session.threshold,
random = ByteVector32(session.round1Random)
).state
/**
* The coordinator's round-1 aggregation. Called twice — once to publish
* `cmsg1`, once to rebuild the state the finalization needs — so the fault
* check lives here rather than at both call sites.
*/
private fun coordinatorStep1(
pmsgs1: List<ByteArray>,
hostPublicKeys: List<PublicKey>,
session: DkgSession
) = ChillDKG.coordinatorStep1(
participantMessages = pmsgs1.map { ByteVector(it) },
hostPublicKeys = hostPublicKeys,
threshold = session.threshold
).also { it.fault.raiseIfFaulty("coordinator round 1") }
/**
* Turns a protocol fault into an exception so it joins [advance]'s single
* failure path.
*
* ChillDKG reports faults as values, not exceptions, because a faulty
* participant is a normal outcome of a DKG rather than a bug. This ritual has
* exactly one response to every one of them — the key is unusable, so the
* session dies and the group is told — so there is nothing to gain from
* handling them individually. What is worth keeping is the culprit: on a
* failed DKG, which participant to blame is the only actionable thing there
* is, and it goes into the failure reason shown to the user.
*/
private fun ChilldkgFault.raiseIfFaulty(step: String) {
if (isOk) return
val culprit = participantIndex?.let { " (participant $it)" } ?: ""
val reason = when (code) {
ChilldkgFault.FAULTY_COORDINATOR -> "the coordinator is faulty"
ChilldkgFault.FAULTY_PARTICIPANT -> "a participant is faulty"
ChilldkgFault.FAULTY_PARTICIPANT_OR_COORDINATOR -> "a participant or the coordinator is faulty"
ChilldkgFault.UNKNOWN_FAULTY_PARTICIPANT_OR_COORDINATOR ->
"an unidentified participant or the coordinator is faulty"
ChilldkgFault.INVALID_INPUT -> "invalid input"
else -> "unrecognised fault code $code"
}
throw IllegalStateException("ChillDKG $step failed: $reason$culprit")
}
/**
* 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.
* matching the [hostPublicKeys] ordering. Null while any are missing: both
* coordinator steps require exactly `n` entries.
*/
private suspend fun orderedPayloads(
database: MantraDatabase,
@@ -475,7 +547,7 @@ object ChillDkgRitualManager {
// 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.
// 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 }
}

View File

@@ -17,11 +17,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
*
* ```
* 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
* participant --[ 30311 host key ]-> everyone host pubkey for the participant set
* participant --[ 30312 round 1 ]-> everyone pmsg1
* coordinator --[ 30313 coord round 1 ]-> everyone cmsg1
* participant --[ 30314 round 2 ]-> everyone CertEq signature
* coordinator --[ 30315 certificate ]-> everyone cmsg2, the success certificate
* anyone --[ 30316 failure ]-> everyone abort + blame
* ```
*
@@ -38,16 +38,16 @@ object DkgRitualEvents {
/** A participant's long-term ChillDKG host public key (33-byte compressed, hex). */
val HOST_KEY: Kind = 30311
/** `ChillDkg.ParticipantMsg1` bytes, hex encoded. */
/** A participant's `pmsg1` from `ChillDKG.participantStep1`, hex encoded. */
val ROUND_1: Kind = 30312
/** `ChillDkg.CoordinatorMsg1` bytes, hex encoded. */
/** The coordinator's `cmsg1` from `ChillDKG.coordinatorStep1`, hex encoded. */
val COORDINATOR_ROUND_1: Kind = 30313
/** `ChillDkg.ParticipantMsg2` bytes (a 64-byte signature), hex encoded. */
/** A participant's 64-byte CertEq signature from `ChillDKG.participantStep2`, hex encoded. */
val ROUND_2: Kind = 30314
/** `ChillDkg.CoordinatorMsg2` bytes (the success certificate), hex encoded. */
/** The coordinator's `cmsg2` — the success certificate hex encoded. */
val CERTIFICATE: Kind = 30315
/** Ritual abandoned. Content is the reason, for showing to the group. */

View File

@@ -5,7 +5,8 @@ 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] —
* proposal so every participant runs the ritual with the same `t` — ChillDKG
* hashes the threshold and the participant set into the session identity, so
* disagreement on `t` makes the session fail rather than silently produce a
* weaker key.
*/