feat: sign a nostr event with the group's shared key
A ceremony leaves every member holding a share of a t-of-n key and no way
to use it. This is the other half: a session that turns an unsigned nostr
event into one signed by the group.
The shape is ChillDkgRitualManager's, deliberately. The member who
proposes coordinates, protocol messages travel as gift-wrapped rumors on
the same NIP-17 pipeline chat messages use, each inbound message is
persisted and then the session is asked whether it can move, and every
step is recomputed from stored inputs so a device killed mid-round
resumes on the next message. Anyone who has read that manager can read
this one.
proposer --[ 30320 proposal ]-> everyone the unsigned event
signer --[ 30321 nonce ]-> everyone this device's public nonce
proposer --[ 30322 signer set ]-> everyone who signs, and their aggregated nonce
signer --[ 30323 partial ]-> everyone this device's partial signature
proposer --[ 30324 signature ]-> everyone the finished 64-byte signature
anyone --[ 30325 failure ]-> everyone abandon + blame
Three things are genuinely different, and each is why this is a separate
manager rather than another branch of that one.
**It does not need everybody.** A DKG cannot finish until every member
takes part; that is what makes the key. Signing needs t, and waiting for
n would throw away the property the group ran a ceremony to get. So the
coordinator waits for the threshold to be reachable, picks a set and says
who is in it. Members left out do nothing and stall nothing.
**Restart-safety is forced rather than chosen.** SecretNonce cannot be
serialised and refuses to be used twice, so storing the randomness it
derives from and regenerating on demand is the only way a session
survives the app closing. That is safe for exactly one reason: a session
signs one message and cannot be made to sign another. Two rules hold it
in place and both are load-bearing rather than tidy:
- the event id is written at creation, and a proposal that disagrees
with it is refused rather than applied;
- the aggregated nonce and signer set are write-once. A coordinator
that sends a second, different set is ignored. Obeying it would mean
two partial signatures over one secret nonce against two challenges,
which is precisely how a secret share is extracted. The session
stalls; the share does not.
**One approval, not three.** A DKG asks three times because each step
publishes something different and commits the member to something
different. Here every step serves one decision -- sign this event or do
not -- and the event is fixed before the member is asked, so a second
prompt would be the same question twice. Declining is broadcast rather
than silent: a t-of-n group can sign without you, but only if it knows.
Two things are checked rather than trusted, both because the coordinator
is untrusted by construction: the event id is recomputed from the
proposal's own fields, so a proposer cannot have the group sign one thing
while showing them another; and the finished signature is verified before
the session is called complete, so a bad aggregate is a failure here
rather than a rejection at every relay it reaches.
Signer ids are derived, not stored: a member's FROST id is their index in
the bytewise sort of the ceremony's host keys, the same ordering ChillDKG
hashed into the session identity and the same one the public shares are
in. Deriving means signing cannot disagree with the ceremony that made
the key.
DkgSession gains publicShares, kept because FROST validates each signer's
secret share against its public one. A ceremony finished before this
column reads back null and signing runs without that check rather than
refusing.
The tests run the same calls in the same order against real FROST and
assert the aggregate verifies as a nostr signature. That path was written
from reading the library rather than from a working example, so it is the
part most likely to be subtly wrong -- and wired up wrong it fails
silently, on every device.
Kinds start at 30320 with a gap. The DKG runs 30310-30316 and the
nip30303 document kinds run 30300 up; those two already collide at 30310
and 30311, and SubmissionEvent sits on 30312, which is also the DKG's
round-1 kind. They are kept apart today only by riding different
transports, which is luck. Signing shares a transport and rooms with the
DKG, so it starts clear of both.
No UI yet: this is the session logic, reachable through proposeSigning,
approve and decline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ 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.FrostSigningSessionDao
|
||||
import press.mantra.compose.database.dao.GiftWrapMessageDao
|
||||
import press.mantra.compose.database.dao.GiftWrapPayloadDao
|
||||
import press.mantra.compose.database.dao.GiftWrapSealDao
|
||||
@@ -102,6 +103,8 @@ 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.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.Relay
|
||||
import press.mantra.compose.database.model.RepostedRelation
|
||||
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
|
||||
@@ -124,6 +127,8 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
ChatRoom::class,
|
||||
DkgParticipantMessage::class,
|
||||
DkgSession::class,
|
||||
FrostSignerMessage::class,
|
||||
FrostSigningSession::class,
|
||||
GiftWrapMessage::class,
|
||||
GiftWrapSeal::class,
|
||||
GiftWrapPayload::class,
|
||||
@@ -164,7 +169,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 5,
|
||||
version = 6,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -183,7 +188,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// nip30303 event a SubmissionEvent rumor carries. Rumors queued before
|
||||
// this come back null, which reads as "not a submission" -- correct,
|
||||
// since none of them were.
|
||||
AutoMigration(from = 4, to = 5)
|
||||
AutoMigration(from = 4, to = 5),
|
||||
// v6 adds the FrostSigningSession/FrostSignerMessage tables and the
|
||||
// nullable DkgSession.publicShares. New tables and a nullable column are
|
||||
// both shapes Room can migrate itself. A ceremony that completed before
|
||||
// this reads back null, and signing falls back to not cross-checking
|
||||
// shares rather than refusing to run.
|
||||
AutoMigration(from = 5, to = 6)
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
@@ -203,6 +214,8 @@ abstract class MantraDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun dkgSessionDao(): DkgSessionDao
|
||||
|
||||
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
|
||||
|
||||
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 com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
|
||||
@Dao
|
||||
interface FrostSigningSessionDao {
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
|
||||
suspend fun getSessionById(sessionId: String): FrostSigningSession?
|
||||
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
|
||||
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
|
||||
|
||||
/**
|
||||
* A room's signing sessions, newest first. Unlike a DKG a group signs
|
||||
* repeatedly, so there is no single "current" one to observe.
|
||||
*/
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
|
||||
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(frostSigningSession: FrostSigningSession)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(frostSignerMessage: FrostSignerMessage)
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC")
|
||||
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<FrostSignerMessage>
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC")
|
||||
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind")
|
||||
suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int
|
||||
|
||||
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind AND signerPublicKey = :signerPublicKey")
|
||||
suspend fun getMessage(sessionId: String, kind: Kind, signerPublicKey: HexKey): FrostSignerMessage?
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventI
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.managers.MarmotInboundManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -960,6 +962,29 @@ abstract class NostrDao(
|
||||
nostrPrivateKey = activeKeyPair.privKey!!
|
||||
)
|
||||
}
|
||||
} else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) {
|
||||
// A FROST signing session message for one of our
|
||||
// NIP-17 groups. Same reasoning as the ritual above:
|
||||
// the manager is idempotent, and the room is created
|
||||
// on demand because membership is the payload's
|
||||
// p-tags either way.
|
||||
val localChatRoom = getOrCreateNip17ChatRoom(
|
||||
decryptedGiftWrapPayload = decryptedGiftWrapPayload,
|
||||
activeKeyPair = activeKeyPair,
|
||||
nostrEventId = nostrEvent.id,
|
||||
relayURL = relayURL
|
||||
)
|
||||
|
||||
if (localChatRoom == null) {
|
||||
logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}")
|
||||
} else {
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = decryptedGiftWrapPayload,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.w("Unsupported event: $decryptedGiftWrapPayload")
|
||||
}
|
||||
|
||||
@@ -179,6 +179,51 @@ data class ChatMessage(
|
||||
* A finished ceremony is the exception, and has no actor: the group ends up
|
||||
* with a key, nobody hands it to them.
|
||||
*/
|
||||
/**
|
||||
* A FROST signing session, as lines in the group's chat.
|
||||
*
|
||||
* The same shape as the ceremony's, and for the same reason: signing with
|
||||
* the group's key is otherwise a black box, and a session stalled on one
|
||||
* member gives no way to see whose door to knock on.
|
||||
*/
|
||||
const val TYPE_FROST_STARTED = "frostStarted"
|
||||
const val TYPE_FROST_NONCE = "frostNonce"
|
||||
const val TYPE_FROST_SIGNER_SET = "frostSignerSet"
|
||||
const val TYPE_FROST_PARTIAL_SIGNATURE = "frostPartialSignature"
|
||||
const val TYPE_FROST_SIGNATURE = "frostSignature"
|
||||
const val TYPE_FROST_COMPLETE = "frostComplete"
|
||||
const val TYPE_FROST_FAILED = "frostFailed"
|
||||
|
||||
/** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */
|
||||
const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded"
|
||||
|
||||
/** Every signing line, for rendering them as system lines rather than bubbles. */
|
||||
val FROST_TYPES = setOf(
|
||||
TYPE_FROST_STARTED,
|
||||
TYPE_FROST_NONCE,
|
||||
TYPE_FROST_SIGNER_SET,
|
||||
TYPE_FROST_PARTIAL_SIGNATURE,
|
||||
TYPE_FROST_SIGNATURE,
|
||||
TYPE_FROST_COMPLETE,
|
||||
TYPE_FROST_FAILED,
|
||||
TYPE_FROST_APPROVAL_NEEDED,
|
||||
)
|
||||
|
||||
/**
|
||||
* The signing lines somebody did, as opposed to ones that simply happened.
|
||||
* Their content is written as a predicate for the actor's name to be read
|
||||
* in front of. A finished signature has no actor: the group ends up with
|
||||
* one, nobody hands it to them.
|
||||
*/
|
||||
val FROST_AUTHORED_TYPES = setOf(
|
||||
TYPE_FROST_STARTED,
|
||||
TYPE_FROST_NONCE,
|
||||
TYPE_FROST_SIGNER_SET,
|
||||
TYPE_FROST_PARTIAL_SIGNATURE,
|
||||
TYPE_FROST_SIGNATURE,
|
||||
TYPE_FROST_FAILED,
|
||||
)
|
||||
|
||||
val DKG_AUTHORED_TYPES = setOf(
|
||||
TYPE_DKG_STARTED,
|
||||
TYPE_DKG_HOST_KEY,
|
||||
|
||||
@@ -9,6 +9,9 @@ import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.PublicKey
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -82,6 +85,17 @@ data class DkgSession(
|
||||
/** Result, secret: this device's FROST secret share, hex. */
|
||||
val secretShare: HexKey? = null,
|
||||
|
||||
/**
|
||||
* Result: every participant's public share, comma-separated hex, in
|
||||
* participant order.
|
||||
*
|
||||
* Not secret, and not needed to finish the ceremony -- kept because signing
|
||||
* is what the key is for and FROST validates each signer's secret share
|
||||
* against its public one. Null on a ceremony that completed before this
|
||||
* column existed; signing still works there, without that check.
|
||||
*/
|
||||
val publicShares: String? = null,
|
||||
|
||||
/** Result: recovery data, to be backed up alongside the host key. */
|
||||
val recoveryData: HexKey? = null,
|
||||
|
||||
@@ -125,4 +139,11 @@ data class DkgSession(
|
||||
override val savedAt: Instant = createdAt,
|
||||
): TimestampedEntity, LocalStoreEntity {
|
||||
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
|
||||
|
||||
/** The participants' public shares in participant order, or null if unrecorded. */
|
||||
fun publicShareList(): List<PublicKey>? = publicShares
|
||||
?.split(",")
|
||||
?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } }
|
||||
?.map { PublicKey(ByteVector(it.hexToByteArray())) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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 signing message from one member, keyed so a duplicate delivery overwrites
|
||||
* rather than accumulates — relays redeliver, and feeding the same nonce in
|
||||
* twice would make the coordinator's signer set the wrong length.
|
||||
*
|
||||
* Covers the nonce and the partial signature; the coordinator's own broadcasts
|
||||
* live on [FrostSigningSession] because there is only ever one of each.
|
||||
*
|
||||
* Keyed on `(sessionId, signerPublicKey, kind)`, which also means a member
|
||||
* cannot replace their own nonce once the coordinator has aggregated it — a
|
||||
* second nonce from the same signer overwrites the first, and the aggregate
|
||||
* built from it simply stops matching. The session's own write-once rule on the
|
||||
* aggregate is what makes that a stalled session rather than a leaked share.
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["sessionId", "signerPublicKey", "kind"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = FrostSigningSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("sessionId"),
|
||||
],
|
||||
)
|
||||
data class FrostSignerMessage(
|
||||
val sessionId: String,
|
||||
|
||||
/** The member's nostr public key — who sent it. */
|
||||
val signerPublicKey: HexKey,
|
||||
|
||||
/** One of the `FrostSigningEvents` kinds. */
|
||||
val kind: Kind,
|
||||
|
||||
/** Hex of the protocol bytes: a public nonce, or a partial signature. */
|
||||
val payload: HexKey,
|
||||
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.model.traits.LocalStoreEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
|
||||
/**
|
||||
* One FROST signing session, as this device sees it.
|
||||
*
|
||||
* Like [DkgSession] this stores *inputs* rather than protocol state, so a
|
||||
* session survives the app being killed halfway through: every step is
|
||||
* recomputed from what is on the row. Unlike a DKG that choice is not merely
|
||||
* convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce`
|
||||
* cannot be serialised and refuses to be used twice, by design.
|
||||
*
|
||||
* ### The nonce, and why one session means one message
|
||||
*
|
||||
* [nonceRandom] is secret, and regenerating this device's nonce from it is safe
|
||||
* for exactly one reason: a session signs one message and can never be made to
|
||||
* sign another. `SecretNonce.generate` mixes the message in, so the same
|
||||
* randomness under a different message would be a different nonce -- but the
|
||||
* same randomness under the same message with two *different* aggregated nonces
|
||||
* would produce two partial signatures over one secret nonce, which is how a
|
||||
* secret share is extracted.
|
||||
*
|
||||
* Two rules keep that impossible, and both are load-bearing:
|
||||
*
|
||||
* - [eventId] is written when the session is created and a proposal that
|
||||
* disagrees with it is rejected rather than applied.
|
||||
* - [aggregatedNonce] and [signerIds] are written once. A second, different
|
||||
* signer set for the same session is ignored, not honoured.
|
||||
*/
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ChatRoom::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["chatRoomId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("chatRoomId"),
|
||||
Index("dkgSessionId"),
|
||||
],
|
||||
)
|
||||
data class FrostSigningSession(
|
||||
/** Minted by the proposer, carried on every message as `frost_session`. */
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val chatRoomId: String,
|
||||
|
||||
/** The member who proposed the signature, who also aggregates for it. */
|
||||
val coordinatorPublicKey: HexKey,
|
||||
|
||||
/** Whose device this row belongs to, for multi-account support. */
|
||||
val userPublicKey: HexKey,
|
||||
|
||||
/** The ceremony whose key this signs with — a group may hold more than one. */
|
||||
val dkgSessionId: String,
|
||||
|
||||
/** The `t` of the t-of-n: how many partial signatures make a signature. */
|
||||
val threshold: Int,
|
||||
|
||||
/** The `n` the key was generated for. FROST needs it to place signer ids. */
|
||||
val participantCount: Int,
|
||||
|
||||
/** This device's FROST id: its index in the ceremony's participant order. */
|
||||
val signerId: Int,
|
||||
|
||||
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
|
||||
/**
|
||||
* The unsigned event, as JSON. Kept whole so a member can be shown what
|
||||
* they are being asked to sign rather than a hash of it.
|
||||
*/
|
||||
val unsignedEventJson: String,
|
||||
|
||||
/**
|
||||
* The event id, which is the 32 bytes actually signed.
|
||||
*
|
||||
* Recomputed from the event's own fields on arrival, never taken from the
|
||||
* proposal. It pins the session to one message -- see the class note on why
|
||||
* that is what makes reusing [nonceRandom] safe.
|
||||
*/
|
||||
val eventId: HexKey,
|
||||
|
||||
/** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */
|
||||
val nonceRandom: HexKey,
|
||||
|
||||
/** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */
|
||||
val aggregatedNonce: HexKey? = null,
|
||||
|
||||
/** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */
|
||||
val signerIds: String? = null,
|
||||
|
||||
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
|
||||
val signature: HexKey? = null,
|
||||
|
||||
val failureReason: String? = null,
|
||||
|
||||
/**
|
||||
* When this device's owner agreed to sign, and with it to everything the
|
||||
* session does on their behalf. Null until they do, and nothing of theirs
|
||||
* goes out before it is set.
|
||||
*
|
||||
* One gate rather than the DKG's three. What a signer is consenting to is
|
||||
* the event, and the event is fixed before they are asked: the second round
|
||||
* puts no new question to them, so asking again would be asking the same
|
||||
* question twice about a decision already made.
|
||||
*/
|
||||
val signApprovedAt: Instant? = null,
|
||||
|
||||
/** Whether the chat line asking for that approval has been written. */
|
||||
val approvalRequestedAt: Instant? = 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
|
||||
|
||||
/** The chosen signers, or null while the coordinator has yet to choose. */
|
||||
fun signerIdList(): List<Int>? = signerIds
|
||||
?.split(",")
|
||||
?.mapNotNull { it.trim().toIntOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** Whether this device was picked to sign. A t-of-n key does not need everyone. */
|
||||
fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package press.mantra.compose.database.model.types
|
||||
|
||||
/**
|
||||
* How far a FROST signing session has got, from the point of view of the device
|
||||
* holding the row. Coordinator and signers move through the same ladder; the
|
||||
* coordinator simply has extra work to do at [COLLECTING_NONCES] and
|
||||
* [COLLECTING_PARTIAL_SIGNATURES].
|
||||
*
|
||||
* Declaration order is the ladder: `FrostSigningManager` compares ordinals to
|
||||
* keep the label moving forwards when messages arrive out of order, so the
|
||||
* collecting stages must stay in the order the session runs them.
|
||||
*/
|
||||
enum class FrostSigningStage {
|
||||
/** Proposal seen; waiting for enough signers to offer a nonce. */
|
||||
COLLECTING_NONCES,
|
||||
|
||||
/** The signer set is fixed; waiting on their partial signatures. */
|
||||
COLLECTING_PARTIAL_SIGNATURES,
|
||||
|
||||
/** Aggregated and verified. The event is signed. */
|
||||
COMPLETE,
|
||||
|
||||
/** Abandoned. See `FrostSigningSession.failureReason`. */
|
||||
FAILED
|
||||
}
|
||||
@@ -574,6 +574,12 @@ object ChillDkgRitualManager {
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(),
|
||||
secretShare = output.secretShare?.value?.toHex(),
|
||||
// Kept for signing, which needs every participant's public
|
||||
// share to place and check the signers. In participant order,
|
||||
// the same order the ids are derived from.
|
||||
publicShares = output.publicShares
|
||||
.joinToString(",") { share -> share.value.toHex() }
|
||||
.takeIf { output.publicShares.isNotEmpty() },
|
||||
recoveryData = output.recovery?.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
package press.mantra.compose.nostr.frost
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
|
||||
|
||||
/**
|
||||
* The nostr kinds a FROST signing session is carried on.
|
||||
*
|
||||
* Like the ChillDKG kinds 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.
|
||||
*
|
||||
* Who talks to whom, in order:
|
||||
*
|
||||
* ```
|
||||
* proposer --[ 30320 proposal ]-> everyone the unsigned event to sign
|
||||
* signer --[ 30321 nonce ]-> everyone this device's public nonce
|
||||
* proposer --[ 30322 signer set ]-> everyone who is signing, and their aggregated nonce
|
||||
* signer --[ 30323 partial ]-> everyone this device's partial signature
|
||||
* proposer --[ 30324 signature ]-> everyone the finished 64-byte signature
|
||||
* anyone --[ 30325 failure ]-> everyone abandon + blame
|
||||
* ```
|
||||
*
|
||||
* ### Why 3032x and not 3031x
|
||||
*
|
||||
* The DKG kinds run 30310-30316 and the nip30303 document kinds run 30300
|
||||
* upwards; those two have already met at 30310 and 30311, and
|
||||
* [press.mantra.compose.nostr.nip30303.SubmissionEvent] sits on 30312, which is
|
||||
* also the DKG's round-1 kind. They are kept apart today only by travelling on
|
||||
* different transports -- documents inside Marmot group events, rituals inside
|
||||
* NIP-17 wraps -- which is luck rather than design.
|
||||
*
|
||||
* Signing runs on the same transport as the DKG and in the same rooms, so it
|
||||
* starts at 30320 with a deliberate gap. Anything added to either family has
|
||||
* room to grow without a second accident.
|
||||
*/
|
||||
object FrostSigningEvents {
|
||||
/**
|
||||
* Opens a session. Content is the unsigned nostr event, as JSON; the key to
|
||||
* sign with is named in [FrostKeyTag].
|
||||
*/
|
||||
val PROPOSAL: Kind = 30320
|
||||
|
||||
/** A signer's `IndividualNonce`, hex encoded. */
|
||||
val NONCE: Kind = 30321
|
||||
|
||||
/**
|
||||
* The coordinator's chosen signers, in [FrostSignerIdsTag], with their
|
||||
* `AggregatedNonce` as the content, hex encoded.
|
||||
*/
|
||||
val SIGNER_SET: Kind = 30322
|
||||
|
||||
/** A signer's 32-byte partial signature, hex encoded. */
|
||||
val PARTIAL_SIGNATURE: Kind = 30323
|
||||
|
||||
/** The finished 64-byte BIP-340 signature over the event id, hex encoded. */
|
||||
val SIGNATURE: Kind = 30324
|
||||
|
||||
/** Session abandoned. Content is the reason, for showing to the group. */
|
||||
val FAILURE: Kind = 30325
|
||||
|
||||
/** Every kind above, for filtering inbound payloads in one check. */
|
||||
val ALL: Set<Kind> = setOf(
|
||||
PROPOSAL,
|
||||
NONCE,
|
||||
SIGNER_SET,
|
||||
PARTIAL_SIGNATURE,
|
||||
SIGNATURE,
|
||||
FAILURE
|
||||
)
|
||||
|
||||
fun isFrostSigningKind(kind: Kind): Boolean = kind in ALL
|
||||
|
||||
/**
|
||||
* Tags for a signing message. The session id is on every kind so a message
|
||||
* from an abandoned attempt can be dropped rather than mixed in.
|
||||
*/
|
||||
fun assembleTags(
|
||||
sessionId: String,
|
||||
dkgSessionId: String? = null,
|
||||
signerIds: List<Int>? = null
|
||||
): Array<Array<String>> = buildList {
|
||||
add(FrostSessionIdTag.assemble(sessionId))
|
||||
dkgSessionId?.let { add(FrostKeyTag.assemble(it)) }
|
||||
signerIds?.let { add(FrostSignerIdsTag.assemble(it)) }
|
||||
}.toTypedArray()
|
||||
|
||||
fun parseSessionId(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(FrostSessionIdTag::parse)?.sessionId
|
||||
|
||||
fun parseKey(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId
|
||||
|
||||
fun parseSignerIds(tags: Array<Array<String>>): List<Int>? =
|
||||
tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package press.mantra.compose.nostr.frost.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Which key the group is being asked to sign with, named by the ceremony that
|
||||
* created it.
|
||||
*
|
||||
* A group can hold more than one shared key -- a ceremony is re-runnable, and
|
||||
* a member leaving is a reason to run another -- and a signer holds a
|
||||
* different secret share under each. Signing with the share from the wrong
|
||||
* ceremony produces a partial signature that cannot aggregate, so the session
|
||||
* says which one from the start rather than leaving each device to guess at
|
||||
* its most recent.
|
||||
*/
|
||||
class FrostKeyTag(
|
||||
val dkgSessionId: String,
|
||||
) {
|
||||
fun toTagArray() = assemble(dkgSessionId = dkgSessionId)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "frost_key"
|
||||
|
||||
fun parse(tag: Array<String>): FrostKeyTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
return FrostKeyTag(dkgSessionId = tag[1])
|
||||
}
|
||||
|
||||
fun assemble(dkgSessionId: String): Array<String> = arrayOf(TAG_NAME, dkgSessionId)
|
||||
|
||||
fun assemble(frostKeyTag: FrostKeyTag) = assemble(dkgSessionId = frostKeyTag.dkgSessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package press.mantra.compose.nostr.frost.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Binds a signing message to one session.
|
||||
*
|
||||
* A group signs more than once with the same key, and an abandoned attempt at
|
||||
* signing one event must never have its messages fed into a live attempt at
|
||||
* another. That is stricter here than it is for a DKG: every signer's secret
|
||||
* nonce is derived per session, so two sessions sharing an id would be two
|
||||
* different messages signed under one nonce -- which is how a secret share
|
||||
* leaks.
|
||||
*/
|
||||
class FrostSessionIdTag(
|
||||
val sessionId: String,
|
||||
) {
|
||||
fun toTagArray() = assemble(sessionId = sessionId)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "frost_session"
|
||||
|
||||
fun parse(tag: Array<String>): FrostSessionIdTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
return FrostSessionIdTag(sessionId = tag[1])
|
||||
}
|
||||
|
||||
fun assemble(sessionId: String): Array<String> = arrayOf(TAG_NAME, sessionId)
|
||||
|
||||
fun assemble(frostSessionIdTag: FrostSessionIdTag) =
|
||||
assemble(sessionId = frostSessionIdTag.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package press.mantra.compose.nostr.frost.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The participants the coordinator chose to sign with, as their FROST ids, in
|
||||
* the order their nonces were aggregated.
|
||||
*
|
||||
* A t-of-n key does not need everyone, so somebody has to pick which t, and
|
||||
* only the coordinator sees every nonce. Every signer then has to build the
|
||||
* same session from the same set in the same order -- FROST binds the set into
|
||||
* the challenge, so a device that disagrees about who is signing produces a
|
||||
* partial signature that will not aggregate.
|
||||
*/
|
||||
class FrostSignerIdsTag(
|
||||
val signerIds: List<Int>,
|
||||
) {
|
||||
fun toTagArray() = assemble(signerIds = signerIds)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "frost_signers"
|
||||
|
||||
fun parse(tag: Array<String>): FrostSignerIdsTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
|
||||
// Order is meaningful, so a single unparseable entry invalidates the
|
||||
// whole list rather than quietly shortening it.
|
||||
val ids = tag[1].split(",").map { it.trim().toIntOrNull() ?: return null }
|
||||
if (ids.isEmpty()) return null
|
||||
|
||||
return FrostSignerIdsTag(signerIds = ids)
|
||||
}
|
||||
|
||||
fun assemble(signerIds: List<Int>): Array<String> =
|
||||
arrayOf(TAG_NAME, signerIds.joinToString(","))
|
||||
|
||||
fun assemble(frostSignerIdsTag: FrostSignerIdsTag) =
|
||||
assemble(signerIds = frostSignerIdsTag.signerIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import fr.acinq.bitcoin.crypto.frost.SecretNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
/**
|
||||
* The two rounds a signing session runs, against real FROST.
|
||||
*
|
||||
* `FrostSigningManager` spreads these steps across arriving messages, several
|
||||
* devices and a database, none of which a unit test can stand up. What it can
|
||||
* do is run the same calls in the same order with the same arguments and check
|
||||
* that what comes out is a signature nostr will accept — which is the part
|
||||
* that was written from reading the library rather than from a working example,
|
||||
* and so the part most likely to be subtly wrong.
|
||||
*
|
||||
* A signature that verifies is the whole contract: if these calls are wired up
|
||||
* incorrectly the aggregate simply fails to verify, silently, on every device.
|
||||
*/
|
||||
class FrostSigningRoundTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
|
||||
|
||||
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
|
||||
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
|
||||
|
||||
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
|
||||
private fun eventId(content: String): String = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = arrayOf(),
|
||||
content = content
|
||||
)
|
||||
|
||||
/**
|
||||
* One signer's half of the protocol, in the manager's order: regenerate the
|
||||
* nonce from stored randomness, then sign once the set is known.
|
||||
*/
|
||||
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32(random),
|
||||
secretShare = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
|
||||
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
|
||||
val aggregated = IndividualNonce.aggregate(nonces).right!!
|
||||
|
||||
return Session.create(
|
||||
aggregatedNonce = aggregated,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a threshold of signers produces a signature nostr accepts`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Two of the three sign, which is the point of a 2-of-3 key.
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"the aggregated signature must verify against the group's x-only key"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different pair of signers signs the same event just as well`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
// Whoever happens to be available. The coordinator picks; the signature
|
||||
// that comes out must not depend on which t it picked.
|
||||
val signerIds = listOf(1, 2)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
|
||||
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = id.hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature over one event does not verify against another`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
|
||||
val signerIds = listOf(0, 1)
|
||||
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
|
||||
val session = sessionFor(signerIds, nonces.map { it.second }, message)
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
|
||||
}
|
||||
val signature = session.aggregateSigs(partials).right!!
|
||||
|
||||
assertFalse(
|
||||
Nip01Crypto.verify(
|
||||
signature = signature.toByteArray(),
|
||||
hash = eventId("the group agrees to something else").hexToByteArray(),
|
||||
pubKey = groupPubKey.hexToByteArray()
|
||||
),
|
||||
"a signature is over one event id and must not carry to another"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
|
||||
val id = eventId("the group agrees")
|
||||
val message = ByteVector(id.hexToByteArray())
|
||||
val random = "d".repeat(63) + "1"
|
||||
|
||||
// What makes a signing session restart-safe: SecretNonce cannot be stored,
|
||||
// so the manager keeps its seed and derives again. If that were not
|
||||
// reproducible a device that restarted mid-session would publish a partial
|
||||
// signature against a nonce nobody aggregated.
|
||||
val first = nonceOf(0, message, random).second
|
||||
val second = nonceOf(0, message, random).second
|
||||
|
||||
assertEquals(first.data.toHex(), second.data.toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same seed under a different message gives a different nonce`() {
|
||||
val random = "e".repeat(63) + "1"
|
||||
|
||||
// The safety property behind reusing the seed at all: one session signs one
|
||||
// message. Were the nonce independent of the message, a session that could
|
||||
// be re-pointed at another event would sign twice under one nonce, which
|
||||
// hands over the secret share.
|
||||
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
|
||||
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
|
||||
|
||||
assertFalse(first.data.toHex() == second.data.toHex())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure bits of a signing session's bookkeeping: who is signing, and with
|
||||
* which key.
|
||||
*/
|
||||
class FrostSigningSessionTest {
|
||||
private fun session(signerId: Int, signerIds: String?) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = signerId,
|
||||
unsignedEventJson = "{}",
|
||||
eventId = "e".repeat(64),
|
||||
nonceRandom = "f".repeat(64),
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a member left out of the signer set is not a signer`() {
|
||||
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
|
||||
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody is a signer until the coordinator has chosen`() {
|
||||
assertFalse(session(signerId = 0, signerIds = null).isSigner())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the signer set keeps the order it was aggregated in`() {
|
||||
// FROST binds the set into the challenge, so this list is not a set of ids
|
||||
// but a sequence positionally matched to the aggregated nonce.
|
||||
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signer set tag survives the trip through a tag array`() {
|
||||
val tags = FrostSigningEvents.assembleTags(
|
||||
sessionId = "session",
|
||||
dkgSessionId = "ceremony",
|
||||
signerIds = listOf(2, 0, 1)
|
||||
)
|
||||
|
||||
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
|
||||
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
|
||||
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
|
||||
// Ceremonies completed before the column existed. Signing falls back to not
|
||||
// cross-checking shares, which the FROST API allows, rather than refusing.
|
||||
val ceremony = DkgSession(
|
||||
id = "k".repeat(64),
|
||||
chatRoomId = "room",
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
hostPublicKey = "h".repeat(66),
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64)
|
||||
)
|
||||
|
||||
assertEquals(null, ceremony.publicShareList())
|
||||
assertEquals(
|
||||
2,
|
||||
ceremony.copy(
|
||||
publicShares = listOf(
|
||||
Hex.encode(ByteArray(33) { 2 }),
|
||||
Hex.encode(ByteArray(33) { 3 })
|
||||
).joinToString(",")
|
||||
).publicShareList()?.size
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user