diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt index 0554151d..8daa315d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt @@ -8,7 +8,9 @@ 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.managers.SubgroupOrigin import press.mantra.compose.nostr.subgroup.tags.SubgroupKeyTag +import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag /** @@ -59,6 +61,14 @@ import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag * somebody joined the child, and the rejection would look exactly like a * forgery. They are shown as history and are never a live roster. * + * The origin -- what the subgroup was made *for* -- is in the same position and + * is checked even less. A certificate may name a dialect, artifact, translation + * or project that this device does not hold and never will, because a subgroup's + * members need never have been in the parent; and a certificate with no origin at + * all is valid, which it has to be, since every one signed before the tag existed + * has none. What the parent's quorum signs is the *intention* that the room be + * for that entity. Nothing vouches that the entity exists, and nothing can. + * * ### Replaceable, by this app rather than by a relay * * Like every kind in this family it only ever exists inside an encryption a @@ -95,13 +105,22 @@ object SubgroupBirthCertificateEvent { thresholdPublicKey: HexKey, adminPublicKeys: List, name: String, - path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, + origin: SubgroupOrigin.Ref? = null ): Array> = arrayOf( DTag.assemble(subgroupChatRoomId), SubgroupParentTag.assemble(parentChatRoomId), SubgroupKeyTag.assemble(thresholdPublicKey), FrostDerivationPathTag.assemble(path), arrayOf(NAME_TAG_NAME, name) + ) + listOfNotNull( + // After the named tags and *before* the run of `p` tags, and the + // position is fixed rather than incidental. A reader that predates this + // takes the tags it knows off the front and ignores the rest; one + // inserted into the admin run would split the founding roster for + // anybody reading it positionally. The same reasoning fixes the order of + // a lineage's pairs in `SubgroupLineage`. + origin?.let { SubgroupOriginTag.assemble(kind = it.kind, id = it.id) } ) + adminPublicKeys.distinct().map { PTag.assemble(it, null) } /** The child this certificate is about, or null if it names none. */ @@ -135,6 +154,24 @@ object SubgroupBirthCertificateEvent { fun parseName(tags: Array>): String? = tags.firstOrNull { it.size > 1 && it[0] == NAME_TAG_NAME }?.get(1)?.ifBlank { null } + /** + * The dialect, artifact, translation or project this subgroup was made for, + * or null when it was made for nothing in particular. + * + * Null is the normal answer for two unrelated reasons and neither is a + * failure: a subgroup started from a group's own "Add subgroup" is for no + * entity, and every certificate signed before this tag existed carries none. + * Null for a *malformed* origin too -- see `SubgroupOriginTag.parse` -- since + * half a reference is not a weaker reference. + * + * Never consulted by [certifies]. See the class comment on the founding + * roster: a certificate is signed once, and nothing on it is compared against + * anything current. + */ + fun parseOrigin(tags: Array>): SubgroupOrigin.Ref? = + tags.firstNotNullOfOrNull(SubgroupOriginTag::parse) + ?.let { SubgroupOrigin.Ref(kind = it.kind, id = it.id) } + /** * Whether [event] is a certificate of [subgroupChatRoomId] that * [parentChatRoomId] actually signed. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt new file mode 100644 index 00000000..1854660c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt @@ -0,0 +1,79 @@ +package press.mantra.compose.nostr.subgroup.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * What a subgroup was made for: a kind, and the id of the event. + * + * A group's room holds dialects, artifacts, translations and projects, and a + * subgroup is often made for exactly one of them -- the room where that + * translation gets worked on. This is the tag that says so, and it is the only + * place the link exists: a room's name is not an identity, and a local column + * would be a fact one device holds. + * + * ### Both halves or neither + * + * [parse] refuses a tag with only one value. An id alone would have to be looked + * up in four tables to be understood, and a reader without the kind cannot tell + * "a project this device does not hold" from "not a project at all". A kind + * alone names a category rather than a thing. `SubgroupOrigin.Ref` has no + * constructor for half of this either, which is the same rule stated where a + * caller would otherwise assemble one by hand. + * + * The pair, and the order, follow `PayloadKindTag`/`PayloadIdTag`: a client + * deciding what it is looking at before parsing the thing itself. + * + * ### It authenticates nothing about the entity + * + * On a certificate the parent's quorum is signing that *it intends* the room to + * be for that entity. It is not vouching that the entity exists, that this + * device can resolve it, or that the entity's own signature is good -- which is + * the limit `docs/project-feature.md` states for a cross-room project + * membership, and it is the right limit here for the same reason. A subgroup's + * members need never have been in the parent, so a certificate naming a row they + * will never hold is the normal case rather than an error. + * + * On a ritual proposal it authenticates even less: anybody can put any id there. + * See `DkgRitualEvents.parseOrigin`. + * + * ### `certifies` never looks at it + * + * Deliberately. The name and the `p` tags on a certificate are the *founding* + * roster -- signed once, never compared against anything current -- and the + * origin joins them. A certificate with no origin at all is valid, which it has + * to be: every certificate signed before this feature has none. + */ +class SubgroupOriginTag( + val kind: Kind, + val id: HexKey, +) { + fun toTagArray() = assemble(kind = kind, id = id) + + companion object { + const val TAG_NAME = "subgroupFor" + + fun parse(tag: Array): SubgroupOriginTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val kind = tag[1].toIntOrNull() ?: return null + + // The length check the nip30303 id tags make. An id of the wrong + // shape is not a thing to carry forward in the hope that something + // downstream refuses it -- nothing downstream would, since nothing + // resolves an origin before showing it. + ensure(tag[2].length == 64) { return null } + + return SubgroupOriginTag(kind = kind, id = tag[2]) + } + + fun assemble(kind: Kind, id: HexKey): Array = + arrayOf(TAG_NAME, kind.toString(), id) + + fun assemble(subgroupOriginTag: SubgroupOriginTag) = + assemble(kind = subgroupOriginTag.kind, id = subgroupOriginTag.id) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index ada04fb2..24300872 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -19,6 +19,12 @@ import kotlin.test.assertTrue import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag /** * What a birth certificate is allowed to convince a member of. @@ -582,6 +588,141 @@ class SubgroupBirthCertificateEventTest { ) } + // --- what the subgroup is for --- + + @Test + fun `an origin round trips for each of the four kinds`() { + listOf( + DialectEvent.KIND, + ArtifactEvent.KIND, + TranslationArtifactVersionEvent.KIND, + ProjectEvent.KIND + ).forEach { kind -> + val entityId = "a1".padEnd(64, '0') + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path, + origin = SubgroupOrigin.Ref(kind = kind, id = entityId) + ) + + assertEquals( + SubgroupOrigin.Ref(kind = kind, id = entityId), + SubgroupBirthCertificateEvent.parseOrigin(tags), + "kind $kind did not read back" + ) + } + } + + @Test + fun `a certificate made for nothing in particular has no origin`() { + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + + assertNull(SubgroupBirthCertificateEvent.parseOrigin(tags)) + assertTrue( + tags.none { it[0] == SubgroupOriginTag.TAG_NAME }, + "an origin tag was written for a subgroup that is for nothing" + ) + } + + /** + * Half a reference is not a weaker reference. A reader without the kind + * cannot tell a project it does not hold from something that is not a + * project, and an id of the wrong length cannot be resolved by anything -- + * nothing downstream would refuse it, because nothing resolves an origin + * before showing it. + */ + @Test + fun `a malformed origin reads as none rather than as half of one`() { + val entityId = "a1".padEnd(64, '0') + + listOf( + arrayOf(SubgroupOriginTag.TAG_NAME, "30330"), + arrayOf(SubgroupOriginTag.TAG_NAME, "not-a-kind", entityId), + arrayOf(SubgroupOriginTag.TAG_NAME, "30330", entityId.dropLast(1)), + arrayOf(SubgroupOriginTag.TAG_NAME, "30330", ""), + arrayOf(SubgroupOriginTag.TAG_NAME) + ).forEach { malformed -> + assertNull( + SubgroupBirthCertificateEvent.parseOrigin(arrayOf(malformed)), + "${malformed.toList()} parsed as an origin" + ) + } + } + + /** + * The origin joins the founding roster, and this is the second half of the + * test above that exists to fail loudly. A certificate naming an entity this + * device does not hold still certifies -- a subgroup's members need never + * have been in the parent, so a certificate pointing at a row they will never + * see is the normal case rather than a forgery. + */ + @Test + fun `a certificate for an entity this device cannot resolve still verifies`() { + val signed = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path, + origin = SubgroupOrigin.Ref( + kind = ProjectEvent.KIND, + id = "beef".padEnd(64, '0') + ) + ) + ) + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = signed, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + /** + * The origin sits before the run of `p` tags, and the position is load + * bearing: a reader that predates it takes the tags it knows off the front, + * and one inserted into the admin run would split the founding roster for + * anybody reading positionally. + */ + @Test + fun `an origin disturbs neither the admins nor the name`() { + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path, + origin = SubgroupOrigin.Ref( + kind = DialectEvent.KIND, + id = "a1".padEnd(64, '0') + ) + ) + + assertEquals(admins, SubgroupBirthCertificateEvent.parseAdminPublicKeys(tags)) + assertEquals(name, SubgroupBirthCertificateEvent.parseName(tags)) + + val originAt = tags.indexOfFirst { it[0] == SubgroupOriginTag.TAG_NAME } + val firstAdminAt = tags.indexOfFirst { it[0] == "p" } + + assertTrue(originAt in 0 until firstAdminAt, "the origin is inside the admin run") + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags(