feat(subgroups): a birth certificate, and the six questions that make one mean anything

Phase 1 of docs/subgroups.md. A group can now say, with a quorum, that another
group is its child -- and any device holding the event can check it without a
database, a lookup or a key it has to be told.

This is the whole of what a subgroup relationship is. A child gets its own
ChillDKG key, its own room, its own quorum and its own admins; nothing is
inherited and nothing is delegated. What the certificate carries is one
checkable claim: the group holding key P said, with a quorum, that the room C is
its child.

**Kind 30329**, 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, and like them it
only ever exists inside an encryption a relay cannot open -- so the addressable
semantics of the 3xxxx range never fire, and the `d` tag is this app's own
newest-wins rule rather than a relay's.

**Content is the child's room id, exactly as specified; the tags are what make
it checkable.** Taken literally a certificate is 32 opaque bytes, and a parent
admin would be asked to put the group's signature to 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 the tags carry the child's threshold key,
the derivation path, its founding admins and its name. The signature covers all
of it, since 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 `subgroup_key`: 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. A coordinator who lies about who
is in the child is then lying in a field the parent's signature covers.

**`certifies` is six questions and no trust.** It is a certificate at all; it is
about this child in both the content and the `d` tag, which have to agree; it
names this parent; the child's id rederives from the key and path it carries; the
parent room signed it; and all of it inside a `runCatching`, because every input
is off the wire and a key that is not a point, a signature that is not 64 bytes
and hex that is not hex all mean the same thing here. The fourth is the half that
does not care who is speaking -- a certificate cannot be pointed at a room the
key it names did not make -- and the fifth is the half that does.

The fifth is `GroupKeyStateEvent.isSignedByRoom` used verbatim rather than
reimplemented. It already asks "did *this room* sign this", and a room id is a
public key here, which is the economy docs/member-chronicle.md is built on. Hex
is compared case-insensitively as that check compares the author, since a
certificate differing in case from what was signed fails the signature anyway --
so all this decides is whether a caller holding the same id in another case gets
a silent drop.

**What `certifies` deliberately does not check, and a test that fails if anybody
adds it.** 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 valid certificates the first time somebody joined a
subgroup, and the rejection would look exactly like a forgery rather than like a
rule. `a certificate still verifies once the subgroup has been renamed and
re-staffed` is there to make that failure loud instead of subtle.

`parseAdminPublicKeys` uses `PTag.parseKey` rather than `PTag.parse`: the relay
hint a full PTag carries is not part of what the parent agreed to, and a hint
that failed to normalise would drop an admin from the roster rather than the hint
from the admin.

Two tag classes in the `FrostDerivationPathTag` shape. `SubgroupParentTag`
checks nothing beyond having a value, because what makes a parent claim mean
anything is the signature and a shape check in front of it would only decide
which of two rejections a bad value gets. `SubgroupKeyTag` borrows its shape
check from `GroupKeyStateEvent.parseThresholdPublicKey` rather than restating it,
so two readers of one value cannot disagree about what a threshold key is.

13 tests, all pure: three independent groups from `Frost.trustedDealerKeygen`, a
real FROST aggregate through the same nonce/signer-set/partial/aggregate shape
`FrostSigningManager.advance` runs, and the author and the signer pulled apart so
that both halves of question five are exercised separately. 386 common tests and
664 jvm tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 23:13:14 +02:00
parent 6fb0af1147
commit 51d6a8841a
4 changed files with 737 additions and 0 deletions

View File

@@ -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<HexKey>,
name: String,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
): Array<Array<String>> = 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<Array<String>>): 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<Array<String>>): 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<Array<String>>): HexKey? =
tags.firstNotNullOfOrNull(SubgroupKeyTag::parse)?.thresholdPublicKey
/** The derivation path, or null if it carries none or an unwalkable one. */
fun parsePath(tags: Array<Array<String>>): List<Long>? =
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<Array<String>>): List<HexKey> =
tags.mapNotNull(PTag::parseKey)
/** What the child was called at founding, or null if it was not named. */
fun parseName(tags: Array<Array<String>>): 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"
}

View File

@@ -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<String>): 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<String> =
arrayOf(TAG_NAME, thresholdPublicKey)
fun assemble(subgroupKeyTag: SubgroupKeyTag) =
assemble(thresholdPublicKey = subgroupKeyTag.thresholdPublicKey)
}
}

View File

@@ -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<String>): 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<String> =
arrayOf(TAG_NAME, parentChatRoomId)
fun assemble(subgroupParentTag: SubgroupParentTag) =
assemble(parentChatRoomId = subgroupParentTag.parentChatRoomId)
}
}

View File

@@ -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<Array<String>> = 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()
}
}