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:
Kgothatso Ngako
2026-09-05 21:38:23 +02:00
parent fcc28de931
commit b4ac65f5c9
16 changed files with 7290 additions and 2 deletions

View File

@@ -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
)
}
}