diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 780d80e3..ff4613f6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -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 = 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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 79c3ab7a..1e3b0083 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -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() + ) ) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt index 2f2d9bd3..70ec49c9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -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 = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parent: SubgroupParentage? = null + lineage: SubgroupLineage? = null ): Array> = 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>): HexKey? = - tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId + fun parseLineage(tags: Array>): 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>): 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>): Boolean = tags.any { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt new file mode 100644 index 00000000..a4d7daa0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt @@ -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, +) { + /** The parents' room ids, in the same canonical order. */ + val parentChatRoomIds: List 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): 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)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index baf9a15e..fb4b7a5b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -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)) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt new file mode 100644 index 00000000..7c8c31df --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt @@ -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) + ) + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 05c24f91..bb0eccc8 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -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) + ) ) ) }