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:
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user