Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user