diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt new file mode 100644 index 00000000..9c1f8155 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt @@ -0,0 +1,198 @@ +package press.mantra.compose.nostr.subgroup + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag +import press.mantra.compose.nostr.subgroup.tags.SubgroupKeyTag +import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag + +/** + * A group saying, with a quorum, that another group is its child. + * + * The whole of what a subgroup relationship is. A child has its own ChillDKG + * key, its own room, its own quorum and its own admins -- nothing is inherited + * and nothing is delegated -- and this is the one thing that ties it to a + * parent: + * + * > The group holding key `P` said, with a quorum, that the room `C` is its + * > child. + * + * Produced by a `FrostSigningEvents.PROPOSAL` in the **parent's** room, so the + * author is the parent room's id and no single member can make one. It then + * travels, whole, on the child's `GroupKeyStateEvent` -- see + * `GroupKeyStateManager.stateFrom`, which is where a state carrying one earns + * its parentage or is dropped. + * + * ``` + * ceremony among the child's admins -> threshold key K, and so C = marmotGroupId(K) + * coordinator --[ 30320 over a 30329 ]-> the parent's room + * ...the parent's quorum signs... + * every parent device holds the certificate "C is ours" + * coordinator --[ 30320 over a 30326 ]-> the child's ceremony room, carrying it + * ``` + * + * ### Content is the id; the tags are what make it checkable + * + * Taken literally a birth certificate is 32 opaque bytes, and a parent admin + * would be asked to sign a number they cannot check, produced by a ceremony most + * of them were not in, on behalf of people they have only the coordinator's word + * about. So [assembleTags] carries the child's threshold key, the derivation + * path, its founding admins and its name. The signature covers all of it -- an + * event id hashes over its tags -- so nothing is added to the claim by putting + * it there, only to what a signer can see before agreeing. + * + * The one that earns its place is [SubgroupKeyTag]: with it a signer's device + * can check `marmotGroupId(key, path) == content` for itself, which is the + * difference between approving a hash and approving a group. + * + * ### What [certifies] deliberately does not check + * + * The name and the `p` tags are the **founding** roster. A certificate is signed + * once; members join and leave and rooms get renamed afterwards, and none of + * that reaches a signature already made. Comparing either against a room's + * current state would start rejecting a valid certificate the first time + * somebody joined the child, and the rejection would look exactly like a + * forgery. They are shown as history and are never a live roster. + * + * ### Replaceable, by this app rather than by a relay + * + * Like every kind in this family it only ever exists inside an encryption a + * relay cannot open, so the addressable semantics of the 3xxxx range never fire. + * [DTag] is the child's room id and the newest certificate per child wins, which + * the local store settles on its own. That matters in practice: two parent + * admins can propose a certificate for the same child at once, and two true + * statements about one child should fold together rather than accumulate. + */ +object SubgroupBirthCertificateEvent { + /** + * Sits past `GroupKeyStateEvent` (30326) and the chronicle pair + * (30327-30328), in the same private inner-event space. Like them it says + * something about a room rather than carrying the room's work. + */ + val KIND: Kind = 30329 + + fun isSubgroupBirthCertificateKind(kind: Kind): Boolean = kind == KIND + + /** + * The tags for a certificate of [subgroupChatRoomId] by [parentChatRoomId]. + * + * The child's room id goes on as the `d` tag as well as in the content, so + * the event is self-addressing and a second certificate for the same child + * replaces the first rather than standing beside it. + * + * [adminPublicKeys] are the child's founding admins, and [name] is what the + * coordinator called it. Both are for the signer to read; see the class + * comment for why neither is checked afterwards. + */ + fun assembleTags( + subgroupChatRoomId: String, + parentChatRoomId: String, + thresholdPublicKey: HexKey, + adminPublicKeys: List, + name: String, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ): Array> = arrayOf( + DTag.assemble(subgroupChatRoomId), + SubgroupParentTag.assemble(parentChatRoomId), + SubgroupKeyTag.assemble(thresholdPublicKey), + FrostDerivationPathTag.assemble(path), + arrayOf(NAME_TAG_NAME, name) + ) + adminPublicKeys.distinct().map { PTag.assemble(it, null) } + + /** The child this certificate is about, or null if it names none. */ + fun parseSubgroupChatRoomId(tags: Array>): String? = + tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null } + + /** The parent doing the certifying, or null if it names none. */ + fun parseParentChatRoomId(tags: Array>): String? = + tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId + + /** The child's threshold key, or null if it carries none of the right shape. */ + fun parseThresholdPublicKey(tags: Array>): HexKey? = + tags.firstNotNullOfOrNull(SubgroupKeyTag::parse)?.thresholdPublicKey + + /** The derivation path, or null if it carries none or an unwalkable one. */ + fun parsePath(tags: Array>): List? = + tags.firstNotNullOfOrNull(FrostDerivationPathTag::parse)?.path + + /** + * The child's founding admins, in the order they were written. + * + * `parseKey` rather than `parse`, because the relay hint a full `PTag` would + * carry is not part of what the parent agreed to -- and a hint that failed to + * normalise would drop an admin from the list rather than the hint from the + * admin. + */ + fun parseAdminPublicKeys(tags: Array>): List = + tags.mapNotNull(PTag::parseKey) + + /** What the child was called at founding, or null if it was not named. */ + fun parseName(tags: Array>): String? = + tags.firstOrNull { it.size > 1 && it[0] == NAME_TAG_NAME }?.get(1)?.ifBlank { null } + + /** + * Whether [event] is a certificate of [subgroupChatRoomId] that + * [parentChatRoomId] actually signed. + * + * The whole of the trust in a parent link, and six questions: + * + * 1. it is a certificate at all; + * 2. it is about this child, in the content and in the `d` tag, which have + * to agree -- the content is the claim and the `d` tag is the address, + * and one pointing somewhere the other does not is not a thing to guess + * the meaning of; + * 3. it names this parent; + * 4. the child's id rederives from the key and path the certificate carries, + * so a certificate cannot be pointed at a room the key it names did not + * make; + * 5. the parent room signed it -- author, id and signature, through the + * check `GroupKeyStateEvent` already makes of a room's own signature; + * 6. all of it inside a `runCatching`, because every input is off the wire: + * a key that is not a point, a signature that is not 64 bytes, hex that + * is not hex. All of them mean the same thing here, which is no. + * + * Point 5 is not reimplemented. `isSignedByRoom` already asks "did *this + * room* sign this", and a room id is a public key -- that is the economy + * `docs/member-chronicle.md` is built on and it applies unchanged. + * + * Hex is compared case-insensitively, as the author check is. A certificate + * that differed in case from what it was signed over would fail point 5 + * anyway, so this only decides whether a caller holding the same id in + * another case gets a silent drop. + */ + fun certifies( + event: Event, + subgroupChatRoomId: String, + parentChatRoomId: String + ): Boolean = runCatching { + if (event.kind != KIND) return false + + if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false + + val addressed = parseSubgroupChatRoomId(event.tags) ?: return false + if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false + + val named = parseParentChatRoomId(event.tags) ?: return false + if (!named.equals(parentChatRoomId, ignoreCase = true)) return false + + val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false + val path = parsePath(event.tags) ?: return false + + val derived = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) + if (!derived.equals(subgroupChatRoomId, ignoreCase = true)) return false + + GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId) + }.getOrDefault(false) + + /** + * The name tag. A bare two-element tag with no class of its own, because + * nothing but the proposal screen and the subgroup list ever reads it and + * neither decides anything on it. + */ + const val NAME_TAG_NAME = "name" +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupKeyTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupKeyTag.kt new file mode 100644 index 00000000..eca3f759 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupKeyTag.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.nostr.subgroup.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * The subgroup's own ChillDKG threshold public key, 33-byte compressed hex. + * + * On a birth certificate this is what makes the thing checkable rather than + * opaque. Without it a parent admin is asked to put the group's signature to 32 + * bytes produced by a ceremony most of them were not in; with it, their device + * can walk `SharedKeyDerivation.marmotGroupId(key, path)` and see for itself that + * the id being certified really is that key's room. + * + * Nothing secret is in here. A threshold public key is what signatures verify + * against, and the shares behind it never leave the devices that generated them. + * + * Shape only, and borrowed rather than restated: `GroupKeyStateEvent` already + * decides what a threshold key looks like off the wire, and two readers of one + * value that disagreed about its shape would be a bug nobody would find. Whether + * it is *the* key for the room is settled by rederiving the room from it, not by + * looking at it. + */ +class SubgroupKeyTag( + val thresholdPublicKey: HexKey, +) { + fun toTagArray() = assemble(thresholdPublicKey = thresholdPublicKey) + + companion object { + const val TAG_NAME = "subgroup_key" + + fun parse(tag: Array): SubgroupKeyTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val thresholdPublicKey = + GroupKeyStateEvent.parseThresholdPublicKey(tag[1]) ?: return null + + return SubgroupKeyTag(thresholdPublicKey = thresholdPublicKey) + } + + fun assemble(thresholdPublicKey: HexKey): Array = + arrayOf(TAG_NAME, thresholdPublicKey) + + fun assemble(subgroupKeyTag: SubgroupKeyTag) = + assemble(thresholdPublicKey = subgroupKeyTag.thresholdPublicKey) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupParentTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupParentTag.kt new file mode 100644 index 00000000..3db7c530 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupParentTag.kt @@ -0,0 +1,52 @@ +package press.mantra.compose.nostr.subgroup.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The group a subgroup is a child of, named by its room id. + * + * Rides on two events and means something different on each, which is worth + * knowing before reading either. + * + * On a `SubgroupBirthCertificateEvent` it is part of what the parent signed, so + * it is the parent naming itself and cannot be anything else -- the signature + * verifies against this very id. + * + * On a `GroupKeyStateEvent` it is an *index* into the certificate travelling + * beside it, which already carries the same value here and as its author. It is + * kept so a reader can answer "whose child is this" without parsing an event out + * of a tag value, and it is not a second source of truth: a state carrying this + * without a certificate, or one whose two copies disagree, is dropped whole. See + * `GroupKeyStateManager.stateFrom`. + * + * A room id is a 32-byte x-only public key -- see `docs/shared-key-derivation.md` + * -- but nothing is checked here beyond the tag having a value. What makes a + * parent claim mean anything is the certificate's signature, and a shape check + * in front of it would only decide which of two rejections a bad value gets. + */ +class SubgroupParentTag( + val parentChatRoomId: HexKey, +) { + fun toTagArray() = assemble(parentChatRoomId = parentChatRoomId) + + companion object { + const val TAG_NAME = "parent_group" + + fun parse(tag: Array): SubgroupParentTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val parentChatRoomId = tag[1].ifBlank { return null } + + return SubgroupParentTag(parentChatRoomId = parentChatRoomId) + } + + fun assemble(parentChatRoomId: HexKey): Array = + arrayOf(TAG_NAME, parentChatRoomId) + + fun assemble(subgroupParentTag: SubgroupParentTag) = + assemble(parentChatRoomId = subgroupParentTag.parentChatRoomId) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt new file mode 100644 index 00000000..8fc32ffd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -0,0 +1,437 @@ +package press.mantra.compose.nostr.subgroup + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation + +/** + * What a birth certificate is allowed to convince a member of. + * + * A certificate is the whole of what a subgroup relationship is, so everything + * downstream of it -- a `GroupKeyState` carrying a parent, a subgroup list, a + * lineage shown to a user -- is worth exactly what `certifies` is worth. It has + * a database nowhere in it on purpose: the deciding is pure, and pure is what + * can be exercised exhaustively. + * + * Two halves again, and they are the same two `GroupKeyStateTest` names. A + * certificate has to be **true** -- the id it certifies rederives from the key + * and path it carries, so it cannot be pointed at a room that key did not make. + * And it has to be the **parent's** -- signed by the room it names as parent, + * which no single member of that room can do. + * + * The last test is the odd one and the most important to keep. It asserts a + * certificate still verifies when its name and admin set have nothing to do with + * the room's current ones, because those are the *founding* roster and the world + * moves. A `certifies` that compared them would start rejecting valid + * certificates the first time somebody joined a subgroup, and the rejection + * would look exactly like a forgery. + */ +class SubgroupBirthCertificateEventTest { + private val participants = 3 + private val threshold = 2 + + /** The parent group: the quorum that does the certifying. */ + private val parentMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** The child: its own ceremony, its own key, its own room. */ + private val childMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2") + ), + nParticipants = participants, + threshold = threshold + ) + + /** A third group entirely, for the certificates signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val childKey = childMaterial.thresholdPublicKey.value.toHex() + private val strangerKey = strangerMaterial.thresholdPublicKey.value.toHex() + + private val parentChatRoomId = + SharedKeyDerivation.marmotGroupId(parentMaterial.thresholdPublicKey.value.toHex(), path) + private val subgroupChatRoomId = SharedKeyDerivation.marmotGroupId(childKey, path) + + private val admins = listOf("a".repeat(64), "b".repeat(64), "c".repeat(64)) + + private val name = "Translation team" + + @Test + fun `a certificate the parent signed for the child it names verifies`() { + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = certificate(), + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate whose content and address disagree is refused`() { + // The content is the claim and the `d` tag is the address. One pointing + // somewhere the other does not is not a thing to guess the meaning of -- + // and guessing the content would let a certificate be filed against a + // child it does not claim. + val misaddressed = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = parentChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = misaddressed, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate whose id does not derive from the key it names is refused`() { + // The attack: certify a room id the parent's admins were shown alongside + // a key that does not make it, so that what they approved and what they + // signed are two different groups. + val mismatched = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = mismatched, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate naming the right key at the wrong path is refused`() { + // The path is half the derivation, so a certificate at m/9420/0/1 names + // a room that is not the one being certified just as surely as a wrong + // key would. + val elsewhere = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = listOf(9420L, 0L, 1L) + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = elsewhere, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate signed by another group is refused`() { + // A real quorum and a real signature, by a group with no standing to say + // anything about this child. Lineage is only ever evidence about the + // parent that signed it. + val stranger = certificate(signer = strangerMaterial, author = strangerMaterial) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = stranger, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate authored by the parent and signed by somebody else is refused`() { + // Says the right things and is authored by the right room, and the + // signature is over that id by a key that is not the room's. This is the + // shape a member forging a lineage would produce. + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = certificate(signer = strangerMaterial), + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate whose signature is not a signature is refused`() { + // Everything off the wire is allowed to be nonsense, and nonsense means + // no rather than an exception. + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = certificate(signature = rubbish), + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "a certificate signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `a certificate whose key is not a point on the curve is refused`() { + // 66 characters of hex, so it passes the shape check and fails the walk. + // `marmotGroupId` throws on it and the catch is what turns that into a no. + val notAKey = certificate( + tags = arrayOf( + arrayOf("d", subgroupChatRoomId), + arrayOf("parent_group", parentChatRoomId), + arrayOf("subgroup_key", "0".repeat(66)), + arrayOf("frost_path", SharedKeyDerivation.formatPath(path)), + arrayOf("name", name) + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = notAKey, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a certificate carrying no parent, no key or no path is refused`() { + listOf("parent_group", "subgroup_key", "frost_path").forEach { dropped -> + val incomplete = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ).filterNot { it[0] == dropped }.toTypedArray() + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = incomplete, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "a certificate with no $dropped tag was accepted" + ) + } + } + + @Test + fun `a certificate still verifies once the subgroup has been renamed and re-staffed`() { + // The one that has to pass. The name and the `p` tags are the founding + // roster: a certificate is signed once and members join and leave + // afterwards. This test exists to fail loudly the day somebody adds the + // roster check that looks obviously missing -- which would reject every + // certificate for a subgroup that has grown, and would look like a + // forgery rather than like a rule. + val founding = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = listOf("d".repeat(64)), + name = "What it was called in 2026", + path = path + ) + ) + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = founding, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `the tags a certificate is proposed on read back as they were written`() { + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + + assertEquals(subgroupChatRoomId, SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(tags)) + assertEquals(parentChatRoomId, SubgroupBirthCertificateEvent.parseParentChatRoomId(tags)) + assertEquals(childKey, SubgroupBirthCertificateEvent.parseThresholdPublicKey(tags)) + assertEquals(path, SubgroupBirthCertificateEvent.parsePath(tags)) + assertEquals(admins, SubgroupBirthCertificateEvent.parseAdminPublicKeys(tags)) + assertEquals(name, SubgroupBirthCertificateEvent.parseName(tags)) + } + + @Test + fun `a duplicated admin is written once`() { + // The p-tags are a roster, and `n` is not read off them by anything -- + // but a list that says somebody twice reads as two people to a human, + // which is the only audience they have. + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins + admins.first(), + name = name, + path = path + ) + + assertEquals(admins, SubgroupBirthCertificateEvent.parseAdminPublicKeys(tags)) + } + + @Test + fun `a threshold key is only read out of a tag that carries one`() { + // Shape only, and borrowed from GroupKeyStateEvent so that the two + // readers of a threshold key cannot disagree about what one is. + assertNull(SubgroupBirthCertificateEvent.parseThresholdPublicKey(arrayOf(arrayOf("subgroup_key", "")))) + assertNull(SubgroupBirthCertificateEvent.parseThresholdPublicKey(arrayOf(arrayOf("subgroup_key", subgroupChatRoomId)))) + assertNull(SubgroupBirthCertificateEvent.parseThresholdPublicKey(arrayOf(arrayOf("subgroup_key", "z".repeat(66))))) + assertNull(SubgroupBirthCertificateEvent.parseThresholdPublicKey(arrayOf(arrayOf("subgroup_key")))) + } + + /** + * A certificate as the app produces one: the caller's fields under the + * parent room's key, with the id hashed over them and the group's own + * signature on it. + * + * [author] decides whose room the event claims to be from and [signer] whose + * quorum actually signs, so the two can be pulled apart -- which is the only + * way to test that both are checked. [signature] replaces the real one + * outright, for the cases where what arrives is not a signature at all. + */ + private fun certificate( + content: String = subgroupChatRoomId, + tags: Array> = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ), + createdAt: Long = 1_700_000_000, + author: KeyMaterial = parentMaterial, + signer: KeyMaterial = author, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(author.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = SubgroupBirthCertificateEvent.KIND, + tags = tags, + content = content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = SubgroupBirthCertificateEvent.KIND, + tags = tags, + content = content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs -- nonces, a signer set, + * partial signatures, an aggregate. + * + * Assembled any other way it would not be evidence about the signatures this + * app actually produces. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .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 = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +}