diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 7cb93895..d95be257 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -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 = 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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt index 4c62807a..2f2d9bd3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -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 = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, + parent: SubgroupParentage? = null ): Array> = 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>): 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>): 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>): 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>): String? = tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupParentage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupParentage.kt new file mode 100644 index 00000000..5da2c2d7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupParentage.kt @@ -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, +) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupBirthCertificateTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupBirthCertificateTag.kt new file mode 100644 index 00000000..0af9b97c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupBirthCertificateTag.kt @@ -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): 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 = + arrayOf(TAG_NAME, certificate.toJson()) + + fun assemble(subgroupBirthCertificateTag: SubgroupBirthCertificateTag) = + assemble(certificate = subgroupBirthCertificateTag.certificate) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index 51d26bb2..baf9a15e 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -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 = 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)) + } }