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:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user