test(subgroups): the join between the pure rules and the columns they write
Phase 9 of docs/subgroups.md. Most of the plan's test work landed with the phase it guarded -- 13 pure cases in Phase 1, 10 more in Phase 3, 21 over a real database in Phases 2, 5 and 8. What was left is the one thing none of them can reach. `GroupKeyStateTest` settles Phase 3's rules purely and exhaustively, and `SubgroupDaoJvmTest` settles that schema 17 holds four columns. Neither can settle the **join**: that a parentage put on a proposal survives a real signing session, a real FROST aggregate and a real `record`, and lands on the *row* on every device rather than only in the event. That is a schema question wearing a protocol question's clothes, and it is exactly the sort of thing that breaks without failing -- `record` could drop both fields and every existing test would still pass. Three cases on the existing two-device harness in `SignedGroupKeyStateTest`, which already runs a whole session across two databases with nothing shared but what is ferried: - a quorum signing a subgroup's state puts the parent and the whole certificate on both devices, neither of which was sent a row -- each derived the event from its own items, re-ran `certifies` against the parent's id, and wrote the same two columns; - a real certificate really signed by the parent but naming another room is refused by `propose` before anything is published, and neither device ends up with a state; - a state signed with no parentage keeps both columns null, which is how every group made before subgroups reads and every top-level group made after. The parent is a second `KeyMaterial` and its signature is a real FROST aggregate assembled by hand. Standing up three more databases to get one would have tested the harness rather than the join. 397 common tests, 708 jvm tests, `m3Audit` meets every budget. All nine phases of docs/subgroups.md are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,12 @@ import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.XonlyPublicKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import fr.acinq.bitcoin.crypto.frost.SecretNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -78,6 +84,22 @@ class SignedGroupKeyStateTest {
|
||||
|
||||
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
|
||||
|
||||
/**
|
||||
* A second group entirely, standing in for the parent when this one is being
|
||||
* made as a subgroup: its own key, its own quorum, its own room.
|
||||
*/
|
||||
private val parentMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val parentRoomId = SharedKeyDerivation.marmotGroupId(
|
||||
parentMaterial.thresholdPublicKey.value.toHex()
|
||||
)
|
||||
|
||||
/** The group's root key as nostr would name it -- what nothing should be signed as. */
|
||||
private val rootPublicKey = XonlyPublicKey(keyMaterial.thresholdPublicKey).value.toHex()
|
||||
|
||||
@@ -1425,4 +1447,180 @@ class SignedGroupKeyStateTest {
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// ---- a state that carries a parentage -----------------------------------
|
||||
//
|
||||
// Phase 3's rules are settled in `GroupKeyStateTest`, purely and
|
||||
// exhaustively. What cannot be settled there is the join: that a parentage
|
||||
// put on a proposal survives a real session, a real aggregate and a real
|
||||
// `record`, and lands on the *row* on every device rather than only in the
|
||||
// event. That is a schema question wearing a protocol question's clothes, and
|
||||
// it is exactly the sort of thing that breaks without failing.
|
||||
|
||||
/**
|
||||
* A birth certificate for this group's admin room, signed by the parent's
|
||||
* quorum.
|
||||
*
|
||||
* Assembled by hand rather than through a second harness: what these tests
|
||||
* need from the parent is one valid signature, and standing up three more
|
||||
* databases to get it would test the harness rather than the join.
|
||||
*/
|
||||
private fun birthCertificate(
|
||||
subgroupChatRoomId: String = adminRoomId,
|
||||
createdAt: Long = 1_700_000_000
|
||||
): Event {
|
||||
val tags = SubgroupBirthCertificateEvent.assembleTags(
|
||||
subgroupChatRoomId = subgroupChatRoomId,
|
||||
parentChatRoomId = parentRoomId,
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
adminPublicKeys = members,
|
||||
name = "Translation team"
|
||||
)
|
||||
|
||||
val pubKey = SharedKeyDerivation
|
||||
.derive(parentMaterial.thresholdPublicKey.value.toHex())
|
||||
.hex
|
||||
|
||||
val id = EventHasher.hashId(
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = tags,
|
||||
content = subgroupChatRoomId
|
||||
)
|
||||
|
||||
return Event(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = tags,
|
||||
content = subgroupChatRoomId,
|
||||
sig = parentSignature(id)
|
||||
)
|
||||
}
|
||||
|
||||
/** A real FROST aggregate by the parent's quorum over [eventId]. */
|
||||
private fun parentSignature(eventId: String): String {
|
||||
val cache = SharedKeyDerivation
|
||||
.derive(parentMaterial.thresholdPublicKey.value.toHex())
|
||||
.cache
|
||||
val message = ByteVector(eventId.hexToByteArray())
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
|
||||
secretShare = parentMaterial.secretShares[signerId],
|
||||
publicShare = parentMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = cache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
}
|
||||
|
||||
val signing = Session.create(
|
||||
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { parentMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = cache,
|
||||
message = message
|
||||
)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
signing.sign(
|
||||
nonces[position].first,
|
||||
parentMaterial.secretShares[signerId],
|
||||
signerId.toUInt()
|
||||
).right!!
|
||||
}
|
||||
|
||||
return signing.aggregateSigs(partials).right!!.toByteArray().toHex()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a quorum signing a subgroup's state puts its parentage on every device`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val certificate = birthCertificate()
|
||||
|
||||
val session = GroupKeyStateManager.propose(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
key = ceremonyOn(creator),
|
||||
parent = SubgroupParentage(
|
||||
parentChatRoomId = parentRoomId,
|
||||
certificate = certificate
|
||||
)
|
||||
)
|
||||
pump(creator, other)
|
||||
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
listOf(creator, other).forEach { device ->
|
||||
val state = assertNotNull(device.keyState(), "every signer should hold the state")
|
||||
|
||||
// The whole join. Neither device was sent a row; both derived the
|
||||
// event from their own items, both re-ran `certifies` against the
|
||||
// parent's id, and both wrote the same two columns.
|
||||
assertEquals(parentRoomId, state.parentChatRoomId)
|
||||
assertEquals(certificate.toJson(), state.birthCertificateJson)
|
||||
assertTrue(state.verifies())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state whose certificate is for another room is dropped by every device`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
// A real certificate, really signed by the parent, for a room that is not
|
||||
// this one. `propose` refuses it before anything is published, which is
|
||||
// the point: a proposal this device would itself drop is a quorum's
|
||||
// attention spent on a statement nobody will keep.
|
||||
assertFailsWith<IllegalStateException> {
|
||||
GroupKeyStateManager.propose(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
key = ceremonyOn(creator),
|
||||
parent = SubgroupParentage(
|
||||
parentChatRoomId = parentRoomId,
|
||||
certificate = birthCertificate(subgroupChatRoomId = parentRoomId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertNull(creator.keyState())
|
||||
assertNull(other.keyState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state signed with no parentage keeps both columns null`() = runBlocking {
|
||||
val creator = device(members[0], signerIndex = 0)
|
||||
val other = device(members[1], signerIndex = 1)
|
||||
|
||||
val session = GroupKeyStateManager.propose(
|
||||
database = creator.db,
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
key = ceremonyOn(creator)
|
||||
)
|
||||
pump(creator, other)
|
||||
FrostSigningManager.approve(other.db, other.room, session.id)
|
||||
pump(creator, other)
|
||||
|
||||
// Every group made before subgroups existed reads back this way, and so
|
||||
// does every top-level group made after.
|
||||
listOf(creator, other).forEach { device ->
|
||||
val state = assertNotNull(device.keyState())
|
||||
assertNull(state.parentChatRoomId)
|
||||
assertNull(state.birthCertificateJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user