Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64

This commit is contained in:
Kgothatso Ngako
2026-09-06 10:07:40 +02:00
30 changed files with 8209 additions and 320 deletions

View File

@@ -20,6 +20,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.extensions.toHex
@@ -159,6 +160,105 @@ class FrostSigningRoundTest {
)
}
@Test
fun `one signer set signs a batch of three, and every signature verifies`() {
// The manager's batch, with the database taken out of the way: one signer
// set and one tweak cache shared, and a nonce, a Session and a signature
// per event. If any of that is wired up wrongly the aggregate simply
// fails to verify, which is the whole reason this file exists.
val ids = listOf("first", "second", "third").map(::eventId)
val messages = ids.map { ByteVector(it.hexToByteArray()) }
val signerIds = listOf(0, 1)
// A seed per signer per item. The manager mints these independently; here
// they only have to differ, which is the property under test.
val nonces = signerIds.map { signerId ->
messages.mapIndexed { index, message ->
nonceOf(signerId, message, "f".repeat(62) + "$index${signerId + 1}")
}
}
val signatures = messages.mapIndexed { index, message ->
val session = sessionFor(signerIds, nonces.map { it[index].second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(
nonces[position][index].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
session.aggregateSigs(partials).right!!
}
ids.forEachIndexed { index, id ->
assertTrue(
Nip01Crypto.verify(
signature = signatures[index].toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"item $index of the batch must verify against the room's own key"
)
}
}
@Test
fun `a batch signature does not carry to another item of the same batch`() {
// What keeps a batch k independent signatures rather than one loose one.
// Signing three events in lockstep must not make any of them
// interchangeable.
val ids = listOf("first", "second").map(::eventId)
val messages = ids.map { ByteVector(it.hexToByteArray()) }
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
messages.mapIndexed { index, message ->
nonceOf(signerId, message, "9".repeat(62) + "$index${signerId + 1}")
}
}
val session = sessionFor(signerIds, nonces.map { it[0].second }, messages[0])
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(
nonces[position][0].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
val first = session.aggregateSigs(partials).right!!
assertFalse(
Nip01Crypto.verify(
signature = first.toByteArray(),
hash = ids[1].hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"item 0's signature must not verify against item 1"
)
}
@Test
fun `two items of a batch never share a nonce`() {
// The one mistake in this whole design that loses the key, asserted at the
// level where it would be made. `SecretNonce.generate` mixes the message
// in, so two items of a batch cannot collide even given the same seed --
// but the manager gives them distinct seeds as well, and both halves are
// checked here because either alone is enough to be relied on by accident.
val messages = listOf("first", "second").map { ByteVector(eventId(it).hexToByteArray()) }
val sameSeed = messages.map { nonceOf(0, it, "7".repeat(64)).second.data.toHex() }
assertEquals(2, sameSeed.toSet().size, "one seed under two messages must give two nonces")
val distinctSeeds = messages.mapIndexed { index, message ->
nonceOf(0, message, "8".repeat(63) + "$index").second.data.toHex()
}
assertEquals(2, distinctSeeds.toSet().size)
assertEquals(emptySet(), sameSeed.toSet().intersect(distinctSeeds.toSet()))
}
@Test
fun `a signature over one event does not verify against another`() {
val id = eventId("the group agrees")
@@ -227,20 +327,30 @@ class FrostSigningSessionTest {
threshold = 2,
participantCount = 3,
signerId = signerId,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signerIds = signerIds
)
/** The one event such a session signs, signed or not. */
private fun items(signature: String? = null) = listOf(
FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signature = signature
)
)
@Test
fun `a session waits on its owner until they answer`() {
val open = session(signerId = 2, signerIds = null)
assertTrue(FrostSigningManager.isAwaitingApproval(open))
assertTrue(FrostSigningManager.isAwaitingApproval(open, items()))
assertFalse(
FrostSigningManager.isAwaitingApproval(
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
open.copy(signApprovedAt = Instant.fromEpochSeconds(1)),
items()
)
)
}
@@ -249,8 +359,12 @@ class FrostSigningSessionTest {
fun `a session that has settled asks its owner nothing`() {
val open = session(signerId = 2, signerIds = null)
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
assertFalse(
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items())
)
assertFalse(
FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items())
)
}
@Test
@@ -261,9 +375,10 @@ class FrostSigningSessionTest {
// decision in that window offers two bad answers: a nonce nobody is
// waiting for, or a refusal that abandons a signature that exists.
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
.copy(signature = "a".repeat(128))
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
assertFalse(
FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128)))
)
}
@Test
@@ -415,7 +530,7 @@ class FrostSigningCompletionTest {
* never named them. Every column the completion path reads is here; the ones
* it must not need are deliberately left null.
*/
private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession(
private fun leftOutMemberSession() = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
@@ -425,24 +540,30 @@ class FrostSigningCompletionTest {
participantCount = 3,
signerId = 2,
derivationPath = SharedKeyDerivation.formatPath(),
signerIds = null,
signApprovedAt = null
)
/** The event that session signs: everything completing it needs, and nothing more. */
private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64),
aggregatedNonce = null,
signerIds = null,
signature = signature,
signApprovedAt = null
signature = signature
)
@Test
fun `a member who never took part can still check what the group signed`() {
val session = leftOutMemberSession(signature)
val signed = FrostSigningManager.signedEvent(session)!!
val item = leftOutMemberItem(signature)
val signed = FrostSigningManager.signedEvent(item)!!
assertTrue(
Nip01Crypto.verify(
signature = signed.sig.hexToByteArray(),
hash = session.eventId.hexToByteArray(),
hash = item.eventId.hexToByteArray(),
pubKey = signed.pubKey.hexToByteArray()
),
"completing must need only the row: the event, its id and the signature"
@@ -454,7 +575,7 @@ class FrostSigningCompletionTest {
// Not rebuilt and not rehashed: the id a session is pinned to is the id
// the signature is over, so anything that changed here would produce an
// event whose signature verifies against nothing.
val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!!
val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!!
assertEquals(unsignedEvent.id, signed.id)
assertEquals(unsignedEvent.pubKey, signed.pubKey)
@@ -466,15 +587,22 @@ class FrostSigningCompletionTest {
@Test
fun `there is no finished event until the signature arrives`() {
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession()))
assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem()))
}
@Test
fun `the arrived signature is what stops the session asking`() {
// The pair that matters to the screen and the transcript: the same row,
// before and after the group finished without this member.
assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession()))
assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature)))
assertTrue(
FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem()))
)
assertFalse(
FrostSigningManager.isAwaitingApproval(
leftOutMemberSession(),
listOf(leftOutMemberItem(signature))
)
)
}
@Test
@@ -482,7 +610,7 @@ class FrostSigningCompletionTest {
// What the check is for. A coordinator passing off something else must not
// get it applied and announced as the group's, and the row is all there is
// to catch it with.
val other = leftOutMemberSession(signature).copy(
val other = leftOutMemberItem(signature).copy(
eventId = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,

View File

@@ -19,7 +19,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
@@ -281,21 +281,14 @@ class GroupKeyStateTest {
val unsigned = unsignedKeyState(content, tags, createdAt, author)
return FrostSigningManager.signedEvent(
session = sessionOver(unsigned),
item = itemOver(unsigned),
signature = groupSignature(signer, unsigned.id)
)
}
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = "c00rd1na70r",
userPublicKey = "c00rd1na70r",
dkgSessionId = "ceremony-1",
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(path),
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)

View File

@@ -170,9 +170,6 @@ class SharedKeyDerivationTest {
threshold = 2,
participantCount = 3,
signerId = 0,
derivationPath = derivationPath,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64)
derivationPath = derivationPath
)
}

View File

@@ -18,7 +18,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.extensions.toHex
@@ -100,24 +100,17 @@ class SignedArtifactTest {
sig = ""
)
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = proposer,
userPublicKey = proposer,
dkgSessionId = "k".repeat(64),
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(),
private fun itemOver(unsignedEvent: Event) = FrostSigningItem(
sessionId = "s".repeat(64),
itemIndex = 0,
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
)
/** A quorum signing the session's event, in the manager's order. */
private fun groupSignature(session: FrostSigningSession): String {
val message = ByteVector(session.eventId.hexToByteArray())
/** A quorum signing the item's event, in the manager's order. */
private fun groupSignature(item: FrostSigningItem): String {
val message = ByteVector(item.eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
@@ -154,8 +147,8 @@ class SignedArtifactTest {
/** Everything from the form to the row a device holds afterwards. */
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel)))
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
return ArtifactEvent(
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
@@ -193,15 +186,15 @@ class SignedArtifactTest {
// to be the one that was signed rather than anything recomputed from the
// proposer. Otherwise members converge on nothing and each holds its own
// copy of what is meant to be one artifact.
val session = sessionOver(unsignedEventOf(proposalTemplate()))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
val item = itemOver(unsignedEventOf(proposalTemplate()))
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
val artifact = MantraArtifact.fromArtifactEvent(
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
chatRoomId
)
assertEquals(session.eventId, artifact?.id)
assertEquals(item.eventId, artifact?.id)
}
@Test

View File

@@ -0,0 +1,98 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* How a proposal's events go on the wire, and what an old build makes of them.
*
* The whole reason this encoding has two shapes rather than one is
* compatibility, and compatibility is exactly the thing no other test in this
* area can see: a build that predates batching is not here to be run against.
* So the property is asserted from the other side -- a batch of one is byte for
* byte the JSON object it always was, which is the only form such a build can
* read.
*/
class FrostProposalCodecTest {
private fun event(content: String, kind: Int = 1) = Event(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1_700_000_000L,
kind = kind,
tags = arrayOf(arrayOf("alt", "test")),
content = content,
sig = ""
)
@Test
fun `a batch of one is the bare event, exactly as it always was`() {
val one = event("just this")
// The load-bearing assertion. A device on a build that predates batching
// parses this with Event.fromJsonOrNull; anything but the bare object
// returns null there and the proposal is dropped, so every single-event
// session in a mixed-version group would stop working.
assertEquals(one.toJson(), FrostSigningEvents.encodeProposal(listOf(one)))
}
@Test
fun `a real batch is an array`() {
val encoded = FrostSigningEvents.encodeProposal(
listOf(event("first"), event("second"))
)
assertTrue(encoded.startsWith("["), "a batch must be a JSON array")
assertTrue(encoded.endsWith("]"))
}
@Test
fun `what is encoded is what comes back, in order`() {
val events = listOf(event("first"), event("second"), event("third"))
val decoded = FrostSigningEvents.decodeProposal(
FrostSigningEvents.encodeProposal(events)
)
// Order is the batch's index, and every join of nonces and partial
// signatures is positional against it.
assertEquals(
events.map { it.content },
decoded?.map { it.content }
)
}
@Test
fun `a bare event decodes as a batch of one, forever`() {
// Proposals in the old shape do not stop arriving just because this build
// stopped writing them.
val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson())
assertEquals(1, decoded?.size)
assertEquals("from an older build", decoded?.single()?.content)
}
@Test
fun `an array with one unreadable element is refused whole`() {
// Not shortened. The batch's length is what every later payload is checked
// against, so a proposal that quietly lost an event would have every
// signer's contribution rejected for being the wrong size.
val encoded = """[${event("fine").toJson()},{"not":"an event"}]"""
assertNull(FrostSigningEvents.decodeProposal(encoded))
}
@Test
fun `an empty array is not a proposal`() {
assertNull(FrostSigningEvents.decodeProposal("[]"))
}
@Test
fun `nonsense is not a proposal`() {
assertNull(FrostSigningEvents.decodeProposal("[this is not json"))
assertNull(FrostSigningEvents.decodeProposal(""))
assertNull(FrostSigningEvents.decodeProposal("null"))
}
}