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:
@@ -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