feat(subgroups): a key state that names its parent, or is dropped for claiming one it cannot back

Phase 3 of docs/subgroups.md. `GroupKeyStateEvent` grows two optional tags and
`GroupKeyStateManager.stateFrom` grows the checks that make them mean something.
Every state signed before this reads exactly as it did: the tags are emitted only
when a parentage is passed, and the new checks fire only on a state carrying one.

```
["parent_group",      <the parent room's id>]
["birth_certificate", <the parent's signed 30329, whole, as JSON>]
```

**Both or neither, and the type says so.** They arrive as one `SubgroupParentage`
rather than as two nullable parameters a caller could half-fill, because a parent
named with no certificate is a claim with the checkable part removed and a
certificate with no parent named beside it has nothing to be an index of. The
certificate is the claim; the parent tag is an index into it, since the
certificate already carries the same value in its own tag and as its author.

**Four checks, and a failure drops the whole state.** Both tags present and
readable; the certificate parses; `SubgroupBirthCertificateEvent.certifies` says
the named parent signed it for *this* room; and the certificate's `subgroup_key`
is the state's own threshold key. The last is belt and braces -- the room's id
already derives from that key and the certificate's id already derives from the
key it names -- and is stated anyway because the two facts live in different files
and a change to either should have to notice this one.

Keeping a failed claim as a *parentless* state was the alternative and is worse.
It is not a state with one field wrong; it is a device asserting a relationship
the parent never agreed to, and filing it would record a group as top-level here
and as a subgroup on every device that could check the certificate.

**`claimsParentage` exists because absent and unreadable are not the same.** A
`birth_certificate` tag carrying `{not json` reads as absent through
`parseBirthCertificate`, so a state claiming a parent in a form nothing can check
would otherwise be filed as an ordinary top-level group. It looks at the tag names
alone, which is the only reading that can tell the two apart.

**The three outcomes needed a wrapper.** `parentageOf` returns `Parentage?` where
null means refused and a present null value means none claimed -- a bare nullable
carries two of the three, and flattening "cannot prove it" into "did not claim
one" is exactly the bug the paragraph above describes.

`propose` takes the same optional parentage and re-runs `certifies` before
opening the session. Every device that receives the state runs that check and
drops it when it fails, so proposing one this device would not believe spends a
quorum's attention on a statement nobody will keep.

**The certificate travels whole rather than as its signature**, argued in
`SubgroupBirthCertificateTag`: a signature plus a rule for rebuilding the event it
covers breaks silently the first time the certificate's shape changes, since a
rebuild differing by one byte hashes to an id whose signature fails and is
indistinguishable from a forgery. Every state already signed would stop being
believed at once, for a reason nothing logs.

Ten tests appended to `GroupKeyStateTest`, using the existing `signedByRoom`
helper so the certificates are real FROST signatures by a real second group: the
happy path keeps both fields; no claim keeps both null; each half alone is
dropped; an unparseable certificate is dropped; a real certificate for another
child stapled on is dropped; one signed by this room's own group rather than the
named parent is dropped; the index disagreeing with the claim is dropped; a
certificate naming another key is dropped; and the tags round-trip, with a
top-level state carrying neither. 396 common tests and 683 jvm tests pass.

The end-to-end through a real session and two databases lands with Phase 9, once
`SubgroupManager` exists to drive one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 23:21:38 +02:00
parent f6217ce6ea
commit 085e598ec5
5 changed files with 406 additions and 4 deletions

View File

@@ -12,6 +12,8 @@ import press.mantra.compose.database.model.GroupKeyState
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.SubgroupParentage
/**
* Puts what key a room signs with to the group, and files what the group says.
@@ -110,6 +112,7 @@ object GroupKeyStateManager {
userPublicKey: HexKey,
key: DkgSession,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
parent: SubgroupParentage? = null,
createdAt: Long = Clock.System.now().epochSeconds
): FrostSigningSession {
val signingRoomId = localChatRoom.chatRoom.id
@@ -134,13 +137,31 @@ object GroupKeyStateManager {
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = userPublicKey,
announcedAt = Instant.fromEpochSeconds(createdAt)
announcedAt = Instant.fromEpochSeconds(createdAt),
parentChatRoomId = parent?.parentChatRoomId,
birthCertificateJson = parent?.certificate?.toJson()
)
check(state.verifies()) {
"Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}"
}
// Refused here rather than discovered by the group. Every device that
// receives this state runs the same check in `parentageOf` and drops the
// state when it fails, so proposing one this device would not believe
// spends a quorum's attention on a statement nobody will keep.
if (parent != null) {
check(
SubgroupBirthCertificateEvent.certifies(
event = parent.certificate,
subgroupChatRoomId = chatRoomId,
parentChatRoomId = parent.parentChatRoomId
)
) {
"Room ${parent.parentChatRoomId} has not certified $chatRoomId as its subgroup"
}
}
logger.i(
"Proposing key ${state.thresholdPublicKey} for room $chatRoomId at " +
"${state.derivationPath}, from $signingRoomId"
@@ -154,7 +175,8 @@ object GroupKeyStateManager {
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = key.id,
path = path
path = path,
parent = parent
),
content = thresholdPublicKey,
key = key,
@@ -322,6 +344,90 @@ object GroupKeyStateManager {
* [chatRoomId] -- where it arrived -- is only the fallback for a state that
* names no room at all, and appears in the logs.
*/
/**
* A parentage that was checked, wrapping a value that is allowed to be
* absent.
*
* Three outcomes have to be told apart and a bare nullable can only carry
* two. A state may claim no parent, which is every group made before
* subgroups and every top-level group made after; it may claim one and prove
* it; or it may claim one it cannot prove, which is not a state with a field
* missing but a device asserting a relationship the parent never agreed to.
* The third has to stop [stateFrom] rather than be flattened into the first.
*
* So `null` here means refused, and a present [value] of `null` means none
* claimed.
*/
private class Parentage(val value: SubgroupParentage?)
/**
* The parentage an event actually establishes, or null if it claims one it
* cannot back.
*
* Four checks, and they exist because a parent link is the one thing on a key
* state that is not self-evident from the room it describes. The rest of
* [stateFrom] is answerable from the event and the room's own id; this is a
* statement about a *second* group, so it needs that group's own signature.
*
* A failure drops the whole state. Keeping it as a parentless one was the
* alternative and is worse: a state claiming a parentage it cannot back is
* not a state with one field wrong, and half-believing it would file a group
* as top-level that its own members will see as a subgroup.
*/
private fun parentageOf(
announced: String,
thresholdPublicKey: HexKey,
innerEvent: Event
): 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)
if (parentChatRoomId == null || certificate == null) {
logger.w(
"Key state for $announced claims a parent in a form that cannot be read " +
"(parent=${parentChatRoomId != null}, certificate=${certificate != null}); dropping"
)
return null
}
// 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
// key it has to be told.
if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
logger.w(
"Key state for $announced carries no certificate by $parentChatRoomId for " +
"that room; dropping"
)
return null
}
// Belt and braces: the id already derives from this key, and the
// certificate's id already derives from the key it names, so two
// certificates for one room cannot name two keys. Stated anyway because
// the two facts live in different places and a future change to either
// should have to notice this.
val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
logger.w(
"Key state for $announced names $thresholdPublicKey and its certificate names " +
"$certifiedKey; dropping"
)
return null
}
return Parentage(
SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate)
)
}
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId
@@ -355,13 +461,17 @@ object GroupKeyStateManager {
return null
}
val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null
val state = GroupKeyState(
chatRoomId = announced,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = innerEvent.pubKey,
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt)
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt),
parentChatRoomId = parent.value?.parentChatRoomId,
birthCertificateJson = parent.value?.certificate?.toJson()
)
// Half the trust model, in one line, and the half that does not care who

View File

@@ -10,6 +10,10 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
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.SubgroupParentage
import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
/**
* What key a Marmot room signs with, signed by the group whose key it is.
@@ -105,13 +109,54 @@ object GroupKeyStateEvent {
fun assembleTags(
chatRoomId: String,
dkgSessionId: String,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
parent: SubgroupParentage? = 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(
SubgroupParentTag.assemble(it.parentChatRoomId),
SubgroupBirthCertificateTag.assemble(it.certificate)
)
} ?: emptyArray()
)
/**
* The group this one claims to be a subgroup of, or null if it claims none.
*
* A *claim* until [SubgroupBirthCertificateEvent.certifies] says otherwise.
* Read alongside [parseBirthCertificate] and never on its own -- see
* `GroupKeyStateManager.stateFrom`.
*/
fun parseParentChatRoomId(tags: Array<Array<String>>): HexKey? =
tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId
/** 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
/**
* Whether the tags carry either 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.
*/
fun claimsParentage(tags: Array<Array<String>>): Boolean =
tags.any {
it.isNotEmpty() &&
(it[0] == SubgroupParentTag.TAG_NAME || it[0] == SubgroupBirthCertificateTag.TAG_NAME)
}
/** The room this state is about, or null if it names none. */
fun parseChatRoomId(tags: Array<Array<String>>): String? =
tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null }

View File

@@ -0,0 +1,29 @@
package press.mantra.compose.nostr.subgroup
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A group's parent, and the parent's signature saying so.
*
* The two travel together everywhere, and this type is what makes that a fact
* the compiler holds rather than a rule two nullable parameters have to be
* trusted to keep. A parent named without a certificate is a claim with the
* checkable part removed, and a certificate with no parent named beside it has
* nothing to be an index of.
*
* [certificate] is the claim; [parentChatRoomId] is an index into it, since the
* certificate already carries the same value in its own `parent_group` tag and
* as its author. The index is kept so a reader can answer "whose child is this"
* without parsing an event out of a tag value, and it is never a second source
* of truth -- see `GroupKeyStateManager.stateFrom`, where the two disagreeing
* drops the state rather than picking one.
*
* Holding one of these is not evidence of anything. Nothing here is checked;
* `SubgroupBirthCertificateEvent.certifies` is where a parentage is believed or
* refused, and it is asked on every read rather than once at the door.
*/
data class SubgroupParentage(
val parentChatRoomId: HexKey,
val certificate: Event,
)

View File

@@ -0,0 +1,49 @@
package press.mantra.compose.nostr.subgroup.tags
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The parent's signed birth certificate, carried whole on the child's key state.
*
* A whole event inside a tag value, which wants justifying. The alternative is a
* bare 64-byte signature plus a rule for rebuilding the event it covers -- kind,
* author, tags, `created_at` -- from fields carried alongside. That rule breaks
* silently the first time the certificate's shape changes: a rebuild differing by
* one byte hashes to an id whose signature does not verify, and is
* indistinguishable from a forgery. Every state signed before the change would
* stop being believed, all at once, for a reason nothing logs.
*
* A few hundred bytes inside an encryption removes that class entirely. The
* event travels as the parent signed it and is checked as the parent signed it.
*
* Nothing here says the certificate is *good*. Parsing is JSON and nothing else;
* whether the parent really signed it, and really signed it for this child, is
* `SubgroupBirthCertificateEvent.certifies` -- see `GroupKeyStateManager.stateFrom`,
* which drops the whole state when it says no.
*/
class SubgroupBirthCertificateTag(
val certificate: Event,
) {
fun toTagArray() = assemble(certificate = certificate)
companion object {
const val TAG_NAME = "birth_certificate"
fun parse(tag: Array<String>): SubgroupBirthCertificateTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val certificate = Event.fromJsonOrNull(tag[1]) ?: return null
return SubgroupBirthCertificateTag(certificate = certificate)
}
fun assemble(certificate: Event): Array<String> =
arrayOf(TAG_NAME, certificate.toJson())
fun assemble(subgroupBirthCertificateTag: SubgroupBirthCertificateTag) =
assemble(certificate = subgroupBirthCertificateTag.certificate)
}
}

View File

@@ -24,6 +24,8 @@ import press.mantra.compose.database.model.FrostSigningItem
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.SubgroupParentage
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
@@ -738,4 +740,171 @@ class GroupKeyStateTest {
assertFalse(GroupKeyStateEvent.isSignedByGroup(signedKeyState(), "", path))
assertFalse(GroupKeyStateEvent.isSignedByGroup(signedKeyState(), thresholdPublicKey, emptyList()))
}
// ---- a state that names a parent --------------------------------------
//
// A subgroup's key state carries the parent group's signature over this very
// room's id. Everything above judges a state against the room it describes,
// which the event alone settles; a parentage is a statement about a *second*
// group, so it is the one claim on a key state that needs somebody else's
// signature to mean anything.
//
// The rule these pin down is that a claim which cannot be backed takes the
// whole state with it. Filing it as a parentless group would be worse than
// dropping it: the group would be recorded as top-level on this device and as
// a subgroup on every device that could check the certificate.
/** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */
private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex
/** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */
private fun birthCertificate(
subgroupChatRoomId: String = chatRoomId,
parent: String = parentChatRoomId,
subgroupKey: String = thresholdPublicKey,
certificatePath: List<Long> = path,
author: KeyMaterial = otherKeyMaterial
): Event = signedByRoom(
kind = SubgroupBirthCertificateEvent.KIND,
content = subgroupChatRoomId,
tags = SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parent,
thresholdPublicKey = subgroupKey,
adminPublicKeys = listOf("a".repeat(64), "b".repeat(64), "c".repeat(64)),
name = "Translation team",
path = certificatePath
),
author = author
)
private fun keyStateWithParent(
parent: String = parentChatRoomId,
certificate: Event = birthCertificate()
): Event = signedKeyState(
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path,
parent = SubgroupParentage(parentChatRoomId = parent, certificate = certificate)
)
)
@Test
fun `a state whose parent certified it keeps the parent and the certificate`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent())
assertEquals(parentChatRoomId, state?.parentChatRoomId)
assertEquals(birthCertificate().toJson(), state?.birthCertificateJson)
}
@Test
fun `a state claiming no parent keeps both fields null`() {
// Every group made before subgroups existed, and every top-level group
// made after. Null here is the ordinary case, not missing data.
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
assertNull(state?.parentChatRoomId)
assertNull(state?.birthCertificateJson)
}
@Test
fun `a state naming a parent with no certificate is dropped`() {
// Not filed as a parentless state. A claim with the checkable part
// removed is not a weaker claim, it is one this device cannot judge, and
// guessing that the member meant nothing by it is still a guess.
val halfClaim = signedKeyState(
tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) +
arrayOf(arrayOf("parent_group", parentChatRoomId))
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, halfClaim))
}
@Test
fun `a state carrying a certificate but naming no parent is dropped`() {
val halfClaim = signedKeyState(
tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) +
arrayOf(arrayOf("birth_certificate", birthCertificate().toJson()))
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, halfClaim))
}
@Test
fun `a state whose certificate will not parse is dropped`() {
// The tag is there and unreadable, which reads as absent through the
// parser and must not read as absent through the decision.
val unreadable = signedKeyState(
tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) +
arrayOf(
arrayOf("parent_group", parentChatRoomId),
arrayOf("birth_certificate", "{not json")
)
)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, unreadable))
}
@Test
fun `a state whose certificate is for another child is dropped`() {
// The attack: take a real certificate the parent signed for one subgroup
// and staple it to another group's key state, so a group the parent never
// certified inherits a lineage it was not given.
val elsewhere = birthCertificate(subgroupChatRoomId = otherKey)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent(certificate = elsewhere)))
}
@Test
fun `a state whose certificate was signed by a room that is not the named parent is dropped`() {
// Signed by this room's own group, which is a real quorum with a real
// signature and no standing to say anything about its own parentage.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
keyStateWithParent(certificate = birthCertificate(author = keyMaterial))
)
)
}
@Test
fun `a state whose parent tag disagrees with its certificate is dropped`() {
// The index and the claim have to agree. Completing one from the other
// would be this device deciding what the group meant.
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent(parent = otherKey)))
}
@Test
fun `a state whose certificate names a different key is dropped`() {
// Belt and braces over the derivation: the certificate's own id has to
// derive from the key it names, so this is a certificate for a room that
// is not this one wearing this room's address.
val wrongKey = birthCertificate(subgroupKey = otherKey)
assertNull(GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent(certificate = wrongKey)))
}
@Test
fun `a parentage survives the trip through the tags`() {
val certificate = birthCertificate()
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path,
parent = SubgroupParentage(parentChatRoomId, certificate)
)
assertTrue(GroupKeyStateEvent.claimsParentage(tags))
assertEquals(parentChatRoomId, GroupKeyStateEvent.parseParentChatRoomId(tags))
assertEquals(certificate.toJson(), GroupKeyStateEvent.parseBirthCertificate(tags)?.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))
}
}