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