feat(subgroups): a certificate can say what the room is for
One optional tag -- `["subgroupFor", "<kind>", "<id>"]` -- and it is the whole of the link between a subgroup and the dialect, artifact, translation or project it was made for. The certificate is the only artefact in the relationship that is signed, travels, and is already read on both sides, so the link goes there rather than in a local column nobody else can see or a room name that is not an identity. The signature already covers the tags, since an event id hashes over them, so nothing is added to the claim by putting it there -- only to what a parent admin can see before agreeing. That is the same bargain `SubgroupKeyTag` made. Both halves or neither. 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. The pair follows `PayloadKindTag`/`PayloadIdTag`, which carry the same two for the same reason. The tag sits after the named tags and before the run of `p` tags, and the position is load bearing rather than incidental: a reader that predates this takes the tags it knows off the front and ignores the rest, while one inserted into the admin run would split the founding roster for anybody reading positionally. `SubgroupLineage` fixes its order for the same kind of reason, and a test asserts the position rather than trusting the comment. `certifies` is untouched, and that is the point of the third test. The origin joins the name and the `p` tags as founding history: a certificate may name a row this device will never hold, because a subgroup's members need never have been in the parent, and a certificate with no origin at all is valid because every one signed before today has none. What the quorum signs is the intention that the room be for that entity; nothing vouches that the entity exists, and nothing can. Phase 4 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HexKey>,
|
||||
name: String,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
|
||||
origin: SubgroupOrigin.Ref? = null
|
||||
): Array<Array<String>> = 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<Array<String>>): 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<Array<String>>): 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.
|
||||
|
||||
@@ -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<String>): 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<String> =
|
||||
arrayOf(TAG_NAME, kind.toString(), id)
|
||||
|
||||
fun assemble(subgroupOriginTag: SubgroupOriginTag) =
|
||||
assemble(kind = subgroupOriginTag.kind, id = subgroupOriginTag.id)
|
||||
}
|
||||
}
|
||||
@@ -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<Array<String>> = SubgroupBirthCertificateEvent.assembleTags(
|
||||
|
||||
Reference in New Issue
Block a user