feat(subgroups): a lineage is a set, and the tags that carry one in pairs
Phase 1 of docs/subgroups-multiple-parents.md, the wire. It ships dark: the event can now state a lineage of any size and nothing in this build produces one of more than one, because the row that would hold it does not exist until the next phase. A room's lineage was never really a field. `GroupKeyState` carried one parent and one certificate because a group was founded with a parent and never gained another; the moment it can be adopted, "the parent" is a set that had only ever held one thing. **`SubgroupLineage` is that set, and it holds the two invariants a caller cannot be trusted to remember.** No parent twice -- two entries naming one group is one parent asked twice, and `of` refuses rather than folding them, because each carries its own certificate and picking one would be this device deciding which of the parent's statements counts. And one canonical order, sorted by parent id, so two devices assembling the same lineage from the same certificates emit the same bytes. `plus` and `minus` are the two edits an adoption and a disavowal make; `minus` returning null is "nobody's child" rather than a failure, and revokes nothing -- a certificate is a signature and exists forever, and what it removes is the child's half of the link, which is the half that decides. **The pairs are adjacent, and that is for other people's builds.** A reader that predates this takes the first `parent_group` and the first `birth_certificate` off the tags and checks them against each other. Emitted as `id, certificate, id, certificate` it gets one real parent and that parent's own certificate, verifies them, and files a true but partial lineage -- one parent, correctly. Emitted as all the ids and then all the certificates it would pair the first id with a certificate that may belong to another parent, fail `certifies`, and drop the **whole state** -- losing the row that says which ceremony's share signs for that room, which is a worse outcome than not knowing about the second parent. The sort is what makes the pairing deterministic rather than incidental. **`parseLineage` counts each half twice, by tag name and by successful parse.** A half that is present and broken reads as absent through a parser, and only the broken kind would slide the pairing along by one and pair an id with somebody else's certificate. So an uneven number of halves, a half that does not parse, and a repeated parent are all refused, and `claimsParentage` -- unchanged, reading tag names alone -- is still what tells "claims nothing" from "claims something unreadable". **Two places refuse a second parent for now, and say so rather than truncating.** `propose` checks the lineage is one before it builds a row that can only hold one, and `parentageOf` drops a state claiming more. Taking the first of two would file a lineage the group did not sign and leave the device believing a group has one parent while its own members see two -- the same reasoning the existing all-or-nothing rule is built on. Nothing emits a second yet, so neither fires except on a state from a newer build, which is exactly the case that wants refusing rather than guessing. `SubgroupParentage` is untouched: still one pair, still unchecked, still the thing a lineage is made of. Nineteen tests. `SubgroupLineageTest` is new and pure, with no crypto at all -- the certificates in it are deliberately nonsense, because whether one is good is `certifies`' question and is asked on every read rather than at the door; it pins the two invariants, case-insensitivity on both, and that a certificate rides along with its own parent through the sort. Five go to `GroupKeyStateTest`, and the one worth keeping if any of this is rewritten asserts the *tag order* and then that the first id and the first certificate an older reader would take are a matched pair. 443 common tests, 933 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97ba7228b
commit
0690932252
7 files changed
+540
-50
No files matched your search
+42
-12
@@ -13,6 +13,7 @@ import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
|
||||
/**
|
||||
@@ -112,7 +113,7 @@ object GroupKeyStateManager {
|
||||
userPublicKey: HexKey,
|
||||
key: DkgSession,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
|
||||
parent: SubgroupParentage? = null,
|
||||
lineage: SubgroupLineage? = null,
|
||||
createdAt: Long = Clock.System.now().epochSeconds
|
||||
): FrostSigningSession {
|
||||
val signingRoomId = localChatRoom.chatRoom.id
|
||||
@@ -121,6 +122,18 @@ object GroupKeyStateManager {
|
||||
"Ceremony ${key.id} has produced no key for $signingRoomId to propose"
|
||||
)
|
||||
|
||||
// The event can carry a lineage of any size; the row cannot yet. Until
|
||||
// `GroupKeyState` holds a set, a caller reaching here with two parents
|
||||
// would have one of them silently dropped on the way to the columns, so
|
||||
// it is refused outright rather than half-kept. The tags are already
|
||||
// plural -- see `GroupKeyStateEvent.assembleTags` -- which is what lets
|
||||
// the wire land before the storage does.
|
||||
check(lineage == null || lineage.size == 1) {
|
||||
"Room $signingRoomId cannot yet propose a key state naming " +
|
||||
"${lineage?.size} parents"
|
||||
}
|
||||
val parent = lineage?.parents?.single()
|
||||
|
||||
// The room this is about: the group's key walked to the admin path,
|
||||
// which is that room's id. It need not exist yet, and in the flow this
|
||||
// was written for it does not.
|
||||
@@ -176,7 +189,7 @@ object GroupKeyStateManager {
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = key.id,
|
||||
path = path,
|
||||
parent = parent
|
||||
lineage = lineage
|
||||
),
|
||||
content = thresholdPublicKey,
|
||||
key = key,
|
||||
@@ -381,22 +394,39 @@ object GroupKeyStateManager {
|
||||
): Parentage? {
|
||||
if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null)
|
||||
|
||||
// Both or neither. A parent named with no certificate is a claim with the
|
||||
// checkable part removed; a certificate with no parent named beside it
|
||||
// has nothing to be an index of. Either alone is refused rather than
|
||||
// completed from the other, because completing it would be this device
|
||||
// deciding what the group meant.
|
||||
val parentChatRoomId = GroupKeyStateEvent.parseParentChatRoomId(innerEvent.tags)
|
||||
val certificate = GroupKeyStateEvent.parseBirthCertificate(innerEvent.tags)
|
||||
// Both or neither, per parent, and no parent twice -- all of which
|
||||
// `parseLineage` decides. A parent named with no certificate is a claim
|
||||
// with the checkable part removed; a certificate with no parent named
|
||||
// beside it has nothing to be an index of. Either alone is refused rather
|
||||
// than completed from the other, because completing it would be this
|
||||
// device deciding what the group meant.
|
||||
val lineage = GroupKeyStateEvent.parseLineage(innerEvent.tags)
|
||||
|
||||
if (parentChatRoomId == null || certificate == null) {
|
||||
if (lineage == null) {
|
||||
logger.w(
|
||||
"Key state for $announced claims a parent in a form that cannot be read " +
|
||||
"(parent=${parentChatRoomId != null}, certificate=${certificate != null}); dropping"
|
||||
"Key state for $announced claims a parentage in a form that cannot be read; " +
|
||||
"dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// One parent is all a row can hold until `GroupKeyState` carries a set.
|
||||
// Refused rather than truncated: taking the first of two would file a
|
||||
// lineage the group did not sign and leave the device believing a group
|
||||
// has one parent when its own members see two. Nothing in this build
|
||||
// emits a second, so this fires only on a state from a newer one.
|
||||
if (lineage.size > 1) {
|
||||
logger.w(
|
||||
"Key state for $announced claims ${lineage.size} parents " +
|
||||
"(${lineage.parentChatRoomIds}), which this build cannot file; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
val parentage = lineage.parents.single()
|
||||
val parentChatRoomId = parentage.parentChatRoomId
|
||||
val certificate = parentage.certificate
|
||||
|
||||
// The parent's own quorum, over this very room's id. Everything that
|
||||
// makes a lineage worth anything is in here -- see
|
||||
// `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no
|
||||
|
||||
@@ -11,6 +11,7 @@ import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
|
||||
/**
|
||||
@@ -320,9 +321,11 @@ object SubgroupManager {
|
||||
userPublicKey = userPublicKey,
|
||||
key = key,
|
||||
path = path,
|
||||
parent = SubgroupParentage(
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
certificate = certificate.toEvent()
|
||||
lineage = SubgroupLineage.of(
|
||||
SubgroupParentage(
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
certificate = certificate.toEvent()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+72
-23
@@ -11,6 +11,7 @@ import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag
|
||||
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
|
||||
@@ -110,46 +111,94 @@ object GroupKeyStateEvent {
|
||||
chatRoomId: String,
|
||||
dkgSessionId: String,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
|
||||
parent: SubgroupParentage? = null
|
||||
lineage: SubgroupLineage? = null
|
||||
): Array<Array<String>> = arrayOf(
|
||||
DTag.assemble(chatRoomId),
|
||||
FrostKeyTag.assemble(dkgSessionId),
|
||||
FrostDerivationPathTag.assemble(path)
|
||||
) + (
|
||||
parent?.let {
|
||||
// Both or neither, which is why this arrives as one value rather
|
||||
// than as two nullable parameters a caller could half-fill. A state
|
||||
// carrying one without the other is dropped on read; emitting one is
|
||||
// therefore only a way to make a state nobody will believe.
|
||||
arrayOf(
|
||||
lineage?.parents?.flatMap {
|
||||
// Both or neither, and **adjacent**: one parent's id immediately
|
||||
// followed by that parent's certificate, in the canonical order
|
||||
// `SubgroupLineage` sorts them into. A state carrying one half
|
||||
// without the other is dropped on read, so emitting one is only a
|
||||
// way to make a state nobody will believe.
|
||||
//
|
||||
// The pairing is what a build predating several parents depends on.
|
||||
// Such a reader takes the first tag of each name and checks them
|
||||
// against each other: emitted in pairs it gets one real parent and
|
||||
// one real certificate for it, verifies them, and files a true but
|
||||
// partial lineage. Emitted as all the ids and then all the
|
||||
// certificates it would pair the first id with a certificate that
|
||||
// may belong to another parent, fail `certifies`, and drop the whole
|
||||
// state -- losing the row that says which ceremony's share signs for
|
||||
// this room, which is a worse outcome than not knowing about the
|
||||
// second parent. See `SubgroupLineage`.
|
||||
listOf(
|
||||
SubgroupParentTag.assemble(it.parentChatRoomId),
|
||||
SubgroupBirthCertificateTag.assemble(it.certificate)
|
||||
)
|
||||
} ?: emptyArray()
|
||||
}?.toTypedArray() ?: emptyArray()
|
||||
)
|
||||
|
||||
/**
|
||||
* The group this one claims to be a subgroup of, or null if it claims none.
|
||||
* The lineage the tags claim, or null if they claim none or claim one that
|
||||
* cannot be read.
|
||||
*
|
||||
* A *claim* until [SubgroupBirthCertificateEvent.certifies] says otherwise.
|
||||
* Read alongside [parseBirthCertificate] and never on its own -- see
|
||||
* `GroupKeyStateManager.stateFrom`.
|
||||
* The two outcomes share a null for the reason every parentage reader here
|
||||
* does: a tag that is present and unparseable looks exactly like an absent
|
||||
* one through a parser. [claimsParentage] is what tells them apart, and
|
||||
* `GroupKeyStateManager.stateFrom` asks it first -- a state claiming a parent
|
||||
* in a form nothing can check is refused rather than filed as top-level.
|
||||
*
|
||||
* Unreadable here means any of: an odd number of halves, a tag of either
|
||||
* name that does not parse, or the same parent named twice. Each is a lineage
|
||||
* this device cannot state back, and completing one from the other halves
|
||||
* would be this device deciding what the group meant.
|
||||
*
|
||||
* **Pairing is positional.** The *n*th `parent_group` goes with the *n*th
|
||||
* `birth_certificate`, which is how [assembleTags] writes them and how a
|
||||
* reader that only ever looks at the first of each reads them. Nothing is
|
||||
* matched up by looking inside the certificates: whether the pair agrees is
|
||||
* `SubgroupBirthCertificateEvent.certifies`' question, asked once per pair,
|
||||
* and a mismatched pair must fail it rather than be quietly re-paired into
|
||||
* one that passes.
|
||||
*/
|
||||
fun parseParentChatRoomId(tags: Array<Array<String>>): HexKey? =
|
||||
tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId
|
||||
fun parseLineage(tags: Array<Array<String>>): SubgroupLineage? {
|
||||
val parentTagCount = tags.count { it.isNotEmpty() && it[0] == SubgroupParentTag.TAG_NAME }
|
||||
val certificateTagCount =
|
||||
tags.count { it.isNotEmpty() && it[0] == SubgroupBirthCertificateTag.TAG_NAME }
|
||||
|
||||
/** The parent's signed certificate, as it signed it, or null if it carries none. */
|
||||
fun parseBirthCertificate(tags: Array<Array<String>>): Event? =
|
||||
tags.firstNotNullOfOrNull(SubgroupBirthCertificateTag::parse)?.certificate
|
||||
if (parentTagCount == 0 || parentTagCount != certificateTagCount) return null
|
||||
|
||||
val parentChatRoomIds = tags.mapNotNull(SubgroupParentTag::parse)
|
||||
.map { it.parentChatRoomId }
|
||||
val certificates = tags.mapNotNull(SubgroupBirthCertificateTag::parse)
|
||||
.map { it.certificate }
|
||||
|
||||
// Counted by tag name above and by successful parse here, so a half that
|
||||
// is present and broken is told from one that was never written. Only
|
||||
// the first would shift the pairing.
|
||||
if (parentChatRoomIds.size != parentTagCount) return null
|
||||
if (certificates.size != certificateTagCount) return null
|
||||
|
||||
return SubgroupLineage.of(
|
||||
parentChatRoomIds.zip(certificates) { parentChatRoomId, certificate ->
|
||||
SubgroupParentage(
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
certificate = certificate
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tags carry either half of a parentage.
|
||||
* Whether the tags carry any half of a parentage.
|
||||
*
|
||||
* The question [parseParentChatRoomId] and [parseBirthCertificate] cannot
|
||||
* answer between them: a tag that is present and unparseable reads as absent
|
||||
* through both. A state claiming a parent in a form nothing can check must be
|
||||
* refused rather than quietly filed as an ordinary top-level group, so this
|
||||
* looks at the tag names alone.
|
||||
* The question [parseLineage] cannot answer, because a tag that is present
|
||||
* and unparseable reads as absent through it. A state claiming a parent in a
|
||||
* form nothing can check must be refused rather than quietly filed as an
|
||||
* ordinary top-level group, so this looks at the tag names alone.
|
||||
*/
|
||||
fun claimsParentage(tags: Array<Array<String>>): Boolean =
|
||||
tags.any {
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package press.mantra.compose.nostr.subgroup
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* Every group a room is a child of, each with the signature saying so.
|
||||
*
|
||||
* A room's lineage is a *set*, and this is the type that makes it one. A group
|
||||
* is founded with one parent and may be adopted by another later -- see
|
||||
* `docs/subgroups-multiple-parents.md` -- so "the parent" was never a field so
|
||||
* much as a set that had only ever held one thing.
|
||||
*
|
||||
* ### Why a type rather than a list
|
||||
*
|
||||
* Two invariants have to hold wherever a lineage is written, and neither is the
|
||||
* sort a caller can be trusted to remember.
|
||||
*
|
||||
* **No parent appears twice.** Two entries naming one group is one parent asked
|
||||
* twice, and a set with a repeat in it is not a set. [of] refuses rather than
|
||||
* folding them, because the two entries carry two certificates and picking one
|
||||
* would be this device deciding which of the parent's statements counts.
|
||||
*
|
||||
* **The order is canonical.** Sorted by parent id, so two devices assembling the
|
||||
* same lineage from the same certificates emit the same bytes. That matters for
|
||||
* one reason only, and it is a reason about other people's builds: a reader that
|
||||
* predates several parents takes the *first* `parent_group` and the *first*
|
||||
* `birth_certificate` off the tags and checks them against each other. Emitted
|
||||
* in sorted pairs, such a reader files a true, partial lineage -- one parent,
|
||||
* verified. Emitted in two unordered runs it would pair one parent's id with
|
||||
* another's certificate, fail the check, and drop the whole state, losing the row
|
||||
* that says which ceremony's share signs for the room. See
|
||||
* `GroupKeyStateEvent.assembleTags`.
|
||||
*
|
||||
* ### Nothing here is evidence
|
||||
*
|
||||
* Holding one of these says only that somebody assembled it. Every certificate
|
||||
* in it is checked by `SubgroupBirthCertificateEvent.certifies` on every read --
|
||||
* see `GroupKeyStateManager.stateFrom`, which drops the whole state when any one
|
||||
* of them fails. This type holds the shape; the signatures hold the trust.
|
||||
*/
|
||||
class SubgroupLineage private constructor(
|
||||
/** The parents, in canonical order. Never empty, never repeating. */
|
||||
val parents: List<SubgroupParentage>,
|
||||
) {
|
||||
/** The parents' room ids, in the same canonical order. */
|
||||
val parentChatRoomIds: List<HexKey> get() = parents.map { it.parentChatRoomId }
|
||||
|
||||
/** How many groups claim this room as a child. At least one. */
|
||||
val size: Int get() = parents.size
|
||||
|
||||
/** Whether [parentChatRoomId] is one of them. Hex, so case does not count. */
|
||||
fun names(parentChatRoomId: HexKey): Boolean =
|
||||
parents.any { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) }
|
||||
|
||||
/**
|
||||
* This lineage with [parent] added, or null if it already names that group.
|
||||
*
|
||||
* An adoption, as the child's quorum will sign it. It takes the whole
|
||||
* resulting set rather than the one addition because a key state is
|
||||
* replaceable and the newest wins *wholesale* -- so a re-announcement that
|
||||
* carried only the new parent would silently drop the old one. Expressing the
|
||||
* addition as an addition is what stops a caller assembling that by hand and
|
||||
* forgetting.
|
||||
*/
|
||||
fun plus(parent: SubgroupParentage): SubgroupLineage? =
|
||||
if (names(parent.parentChatRoomId)) null else of(parents + parent)
|
||||
|
||||
/**
|
||||
* This lineage without [parentChatRoomId], or null when it was the last one.
|
||||
*
|
||||
* Null is "no lineage" rather than a failure, and is what a caller passes on
|
||||
* to say a room is nobody's child. Dropping a parent that is not named
|
||||
* returns this lineage unchanged, since the result is the same either way.
|
||||
*
|
||||
* It does not revoke anything. The certificate is a signature and exists
|
||||
* forever; what this removes is the child's half of the link, which is the
|
||||
* half that decides. The parent's own list is built from its certificates and
|
||||
* will go on naming the child -- see `SubgroupManager.subgroupsOf`.
|
||||
*/
|
||||
fun minus(parentChatRoomId: HexKey): SubgroupLineage? = of(
|
||||
parents.filterNot { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) }
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A lineage of [parents], or null if there are none or one repeats.
|
||||
*
|
||||
* Null for empty is deliberate and is the same null every parentage API
|
||||
* here uses for "nobody's child": a room with no parents has no lineage
|
||||
* rather than an empty one, so there is one way to say it instead of two.
|
||||
*/
|
||||
fun of(parents: List<SubgroupParentage>): SubgroupLineage? {
|
||||
if (parents.isEmpty()) return null
|
||||
|
||||
val distinct = parents.distinctBy { it.parentChatRoomId.lowercase() }
|
||||
if (distinct.size != parents.size) return null
|
||||
|
||||
return SubgroupLineage(distinct.sortedBy { it.parentChatRoomId.lowercase() })
|
||||
}
|
||||
|
||||
/**
|
||||
* A lineage of one, which cannot fail -- a single entry has nothing to
|
||||
* repeat. The founding case, and the shape every state signed before
|
||||
* several parents existed is read back as.
|
||||
*/
|
||||
fun of(parent: SubgroupParentage): SubgroupLineage = SubgroupLineage(listOf(parent))
|
||||
}
|
||||
}
|
||||
+140
-6
@@ -25,7 +25,10 @@ import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
@@ -786,7 +789,9 @@ class GroupKeyStateTest {
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
parent = SubgroupParentage(parentChatRoomId = parent, certificate = certificate)
|
||||
lineage = SubgroupLineage.of(
|
||||
SubgroupParentage(parentChatRoomId = parent, certificate = certificate)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -892,19 +897,148 @@ class GroupKeyStateTest {
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
parent = SubgroupParentage(parentChatRoomId, certificate)
|
||||
lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
|
||||
)
|
||||
|
||||
assertTrue(GroupKeyStateEvent.claimsParentage(tags))
|
||||
assertEquals(parentChatRoomId, GroupKeyStateEvent.parseParentChatRoomId(tags))
|
||||
assertEquals(certificate.toJson(), GroupKeyStateEvent.parseBirthCertificate(tags)?.toJson())
|
||||
|
||||
val lineage = GroupKeyStateEvent.parseLineage(tags)
|
||||
|
||||
assertEquals(listOf(parentChatRoomId), lineage?.parentChatRoomIds)
|
||||
assertEquals(certificate.toJson(), lineage?.parents?.single()?.certificate?.toJson())
|
||||
|
||||
// And the tags a top-level group is proposed on carry neither, so every
|
||||
// state signed before subgroups existed reads exactly as it did.
|
||||
val plain = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
|
||||
|
||||
assertFalse(GroupKeyStateEvent.claimsParentage(plain))
|
||||
assertNull(GroupKeyStateEvent.parseParentChatRoomId(plain))
|
||||
assertNull(GroupKeyStateEvent.parseBirthCertificate(plain))
|
||||
assertNull(GroupKeyStateEvent.parseLineage(plain))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two parents land as adjacent pairs, each id before its own certificate`() {
|
||||
// The one thing in this design that exists for *other people's* builds. A
|
||||
// reader that predates several parents takes the first `parent_group` and
|
||||
// the first `birth_certificate` and checks them against each other, so
|
||||
// the pairs have to be adjacent and in step. Were they emitted as all the
|
||||
// ids and then all the certificates, such a reader would pair one
|
||||
// parent's id with another's certificate, fail `certifies`, and drop the
|
||||
// whole state -- losing the row that says which share signs for the room.
|
||||
val second = birthCertificate(parent = chatRoomId, author = keyMaterial)
|
||||
val tags = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(
|
||||
listOf(
|
||||
SubgroupParentage(parentChatRoomId, birthCertificate()),
|
||||
SubgroupParentage(chatRoomId, second)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val names = tags.map { it[0] }.filter {
|
||||
it == SubgroupParentTag.TAG_NAME || it == SubgroupBirthCertificateTag.TAG_NAME
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SubgroupParentTag.TAG_NAME,
|
||||
SubgroupBirthCertificateTag.TAG_NAME,
|
||||
SubgroupParentTag.TAG_NAME,
|
||||
SubgroupBirthCertificateTag.TAG_NAME
|
||||
),
|
||||
names
|
||||
)
|
||||
|
||||
// And the first pair an older reader would take is a matched one: the
|
||||
// certificate beside the first id is the certificate *by* that parent.
|
||||
val firstParent = tags.first { it[0] == SubgroupParentTag.TAG_NAME }[1]
|
||||
val firstCertificate = tags.first { it[0] == SubgroupBirthCertificateTag.TAG_NAME }[1]
|
||||
|
||||
assertEquals(firstParent, Event.fromJson(firstCertificate).pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state claiming two parents is dropped rather than truncated`() {
|
||||
// Until a row can hold a set. Taking the first of two would file a
|
||||
// lineage the group did not sign, and leave this device believing a group
|
||||
// has one parent while its own members see two.
|
||||
val second = birthCertificate(parent = chatRoomId, author = keyMaterial)
|
||||
val state = signedKeyState(
|
||||
tags = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(
|
||||
listOf(
|
||||
SubgroupParentage(parentChatRoomId, birthCertificate()),
|
||||
SubgroupParentage(chatRoomId, second)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state naming the same parent twice is dropped`() {
|
||||
val certificate = birthCertificate()
|
||||
val doubled = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
|
||||
) + arrayOf(
|
||||
SubgroupParentTag.assemble(parentChatRoomId),
|
||||
SubgroupBirthCertificateTag.assemble(certificate)
|
||||
)
|
||||
|
||||
assertTrue(GroupKeyStateEvent.claimsParentage(doubled))
|
||||
assertNull(GroupKeyStateEvent.parseLineage(doubled))
|
||||
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = doubled)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an uneven number of halves is refused, not completed from the other`() {
|
||||
val certificate = birthCertificate()
|
||||
|
||||
val extraParent = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
|
||||
) + arrayOf(SubgroupParentTag.assemble(chatRoomId))
|
||||
|
||||
assertNull(GroupKeyStateEvent.parseLineage(extraParent))
|
||||
|
||||
val extraCertificate = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
|
||||
) + arrayOf(SubgroupBirthCertificateTag.assemble(certificate))
|
||||
|
||||
assertNull(GroupKeyStateEvent.parseLineage(extraCertificate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a half that is present and broken shifts nothing, because it is counted`() {
|
||||
// Counted by tag name as well as by successful parse, so a broken half is
|
||||
// told from one that was never written. Only the first would slide the
|
||||
// pairing along by one and pair an id with somebody else's certificate.
|
||||
val broken = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, birthCertificate()))
|
||||
) + arrayOf(
|
||||
SubgroupParentTag.assemble(chatRoomId),
|
||||
arrayOf(SubgroupBirthCertificateTag.TAG_NAME, "not an event")
|
||||
)
|
||||
|
||||
assertTrue(GroupKeyStateEvent.claimsParentage(broken))
|
||||
assertNull(GroupKeyStateEvent.parseLineage(broken))
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package press.mantra.compose.nostr.subgroup
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The two invariants a lineage holds, and the three edits it allows.
|
||||
*
|
||||
* Nothing here signs anything. `SubgroupLineage` is about the *shape* of a
|
||||
* parentage set -- no repeats, one canonical order -- and the certificates in it
|
||||
* are deliberately nonsense, because whether a certificate is good is
|
||||
* `SubgroupBirthCertificateEvent.certifies`' question and is asked on every read
|
||||
* rather than at the door. A test here that needed a real signature would be
|
||||
* testing the wrong class.
|
||||
*/
|
||||
class SubgroupLineageTest {
|
||||
private val alpha = "a".repeat(64)
|
||||
private val beta = "b".repeat(64)
|
||||
private val gamma = "c".repeat(64)
|
||||
|
||||
@Test
|
||||
fun `no parents is no lineage rather than an empty one`() {
|
||||
assertNull(SubgroupLineage.of(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one parent cannot fail, because it has nothing to repeat`() {
|
||||
val lineage = SubgroupLineage.of(parentage(alpha))
|
||||
|
||||
assertEquals(1, lineage.size)
|
||||
assertEquals(listOf(alpha), lineage.parentChatRoomIds)
|
||||
assertTrue(lineage.names(alpha))
|
||||
assertFalse(lineage.names(beta))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same parent twice is refused rather than folded`() {
|
||||
// One parent asked twice, carrying two certificates. Folding them would
|
||||
// be this device picking which of the parent's statements counts.
|
||||
assertNull(SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeat is a repeat whatever case it is written in`() {
|
||||
val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha.uppercase())))
|
||||
|
||||
assertNull(lineage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a room id is matched case-insensitively, because it is hex`() {
|
||||
val lineage = SubgroupLineage.of(parentage(alpha))
|
||||
|
||||
assertTrue(lineage.names(alpha.uppercase()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the order is the parents' ids, whatever order they arrived in`() {
|
||||
// The whole reason this class sorts: two devices assembling one lineage
|
||||
// from the same certificates have to emit the same bytes, or a reader
|
||||
// that pairs the first tag of each name gets a mismatched pair.
|
||||
val forwards = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta), parentage(gamma)))
|
||||
val backwards = SubgroupLineage.of(listOf(parentage(gamma), parentage(beta), parentage(alpha)))
|
||||
|
||||
assertEquals(listOf(alpha, beta, gamma), forwards?.parentChatRoomIds)
|
||||
assertEquals(listOf(alpha, beta, gamma), backwards?.parentChatRoomIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sorting is on the id and not on the case it is written in`() {
|
||||
val lineage = SubgroupLineage.of(listOf(parentage(beta.uppercase()), parentage(alpha)))
|
||||
|
||||
// Sorted by the lowercased id, and the id itself is kept as it arrived --
|
||||
// the certificate is the authority on what the parent is called, and this
|
||||
// tag is only an index into it.
|
||||
assertEquals(listOf(alpha, beta.uppercase()), lineage?.parentChatRoomIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a parent is an addition, not a replacement`() {
|
||||
// A key state is replaceable and the newest wins wholesale, so an
|
||||
// adoption that carried only the new parent would silently drop the old
|
||||
// one. Expressing it as an addition is what stops a caller assembling
|
||||
// the resulting set by hand and forgetting half of it.
|
||||
val adopted = SubgroupLineage.of(parentage(beta)).plus(parentage(alpha))
|
||||
|
||||
assertEquals(listOf(alpha, beta), adopted?.parentChatRoomIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a parent that is already there is refused`() {
|
||||
assertNull(SubgroupLineage.of(parentage(alpha)).plus(parentage(alpha)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping the last parent leaves no lineage at all`() {
|
||||
// Null is "nobody's child", which is what a caller passes on. It revokes
|
||||
// nothing: the certificate is a signature and exists forever, and what
|
||||
// this removes is the child's half of the link.
|
||||
assertNull(SubgroupLineage.of(parentage(alpha)).minus(alpha))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping one of two leaves the other`() {
|
||||
val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta)))
|
||||
|
||||
assertEquals(listOf(beta), lineage?.minus(alpha)?.parentChatRoomIds)
|
||||
assertEquals(listOf(alpha), lineage?.minus(beta)?.parentChatRoomIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping a parent that was never there changes nothing`() {
|
||||
val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta)))
|
||||
|
||||
assertEquals(listOf(alpha, beta), lineage?.minus(gamma)?.parentChatRoomIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the certificates ride along with their own parents`() {
|
||||
// The pairing is the point of the type: an id and the certificate beside
|
||||
// it have to stay together through the sort, or `certifies` would be
|
||||
// asked about a pair the group never signed.
|
||||
val lineage = SubgroupLineage.of(listOf(parentage(gamma), parentage(alpha)))
|
||||
val parents = lineage?.parents.orEmpty()
|
||||
|
||||
assertEquals(alpha, parents[0].parentChatRoomId)
|
||||
assertEquals(alpha, parents[0].certificate.content)
|
||||
assertEquals(gamma, parents[1].parentChatRoomId)
|
||||
assertEquals(gamma, parents[1].certificate.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parentage is carried as it was handed over`() {
|
||||
val parentage = parentage(alpha)
|
||||
|
||||
assertSame(parentage, SubgroupLineage.of(parentage).parents.single())
|
||||
}
|
||||
|
||||
/**
|
||||
* A parentage whose certificate says only which parent it belongs to, so a
|
||||
* test can tell two of them apart after a sort. Not a valid certificate and
|
||||
* not meant to be one.
|
||||
*/
|
||||
private fun parentage(parentChatRoomId: String) = SubgroupParentage(
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
certificate = Event(
|
||||
id = "d".repeat(64),
|
||||
pubKey = parentChatRoomId,
|
||||
createdAt = 1_700_000_000,
|
||||
kind = SubgroupBirthCertificateEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = parentChatRoomId,
|
||||
sig = "e".repeat(128)
|
||||
)
|
||||
)
|
||||
}
|
||||
+11
-6
@@ -11,6 +11,7 @@ import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.XonlyPublicKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
@@ -1554,9 +1555,11 @@ class SignedGroupKeyStateTest {
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
key = ceremonyOn(creator),
|
||||
parent = SubgroupParentage(
|
||||
parentChatRoomId = parentRoomId,
|
||||
certificate = certificate
|
||||
lineage = SubgroupLineage.of(
|
||||
SubgroupParentage(
|
||||
parentChatRoomId = parentRoomId,
|
||||
certificate = certificate
|
||||
)
|
||||
)
|
||||
)
|
||||
pump(creator, other)
|
||||
@@ -1591,9 +1594,11 @@ class SignedGroupKeyStateTest {
|
||||
localChatRoom = creator.room,
|
||||
userPublicKey = creator.publicKey,
|
||||
key = ceremonyOn(creator),
|
||||
parent = SubgroupParentage(
|
||||
parentChatRoomId = parentRoomId,
|
||||
certificate = birthCertificate(subgroupChatRoomId = parentRoomId)
|
||||
lineage = SubgroupLineage.of(
|
||||
SubgroupParentage(
|
||||
parentChatRoomId = parentRoomId,
|
||||
certificate = birthCertificate(subgroupChatRoomId = parentRoomId)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user