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:
Kgothatso Ngako
2026-09-08 23:51:42 +02:00
parent b10c79f9ed
commit 3ae49bd338

View File

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