From 759a39c9af4eba1771440fba94a79a693f6a6a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:13:45 +0000 Subject: [PATCH] refactor(mls): put the binding's rules behind MlsGroupPolicy The engine knew the word "admin", which RFC 9420 does not have. MIP-03's authorization gate, the admin-depletion guard, the agent-text-stream role check on join and the self-demote-before-SelfRemove rule all ran inside `MlsGroup`, and `MlsGroup.create` defaulted every group to Marmot's leaf capabilities and `required_capabilities`. A plain RFC 9420 group could not be created at all -- every group came out requiring `marmot_group_data` -- and `commit()` derived its pre-commit secret under a hardcoded `MLS-Exporter("marmot", "group-event", 32)`. `MlsGroupPolicy` is the seam. Three hooks the engine calls where RFC 9420 defers to the application (authorizeCommit, authorizeSelfRemove, validateJoin) and four values it reads (leaf capabilities, required_capabilities, extra known extension types, the commit exporter label). `MarmotGroupPolicy` carries all of them, with the enforcement bodies moved verbatim. One argument now selects a whole profile: `MlsGroup.create(id, policy = MarmotGroupPolicy)` gets the rules, the capabilities and the exporter binding together, which is why the change is one added argument per call site rather than five. The default is permissive, and that direction is a deliberate trade. Closed would make the engine unusable without a policy and would push callers into writing an allow-everything one anyway. The cost is that a group restored without its policy silently drops the binding's rules -- a policy is behaviour, not state, so it is not in `MlsGroupState`. All ten production construction sites are in `marmot/` and all ten now name it. Policies see a read-only `GroupView`, not the group. A policy handed the `MlsGroup` could commit or rotate keys from inside the check meant to gate those things. The tests were the safety net, exactly as intended: the first run after the defaults changed failed 13, every one a Marmot test that had been relying on `MlsGroup.create` to make its group Marmot-shaped. Those now say so. The other ~180 construction sites kept passing on the permissive default, which is itself the result worth having -- they are engine tests and they no longer need Marmot to run. Adds 8 tests for the seam itself. Five pin the engine half with a recording policy (the hooks are consulted, a refusal aborts before the epoch moves, a refused SelfRemove is not staged, the exporter secret comes from the policy). Three pin the divergence from Marmot's side, including the half no other test covers: the same group at the same state accepts the same commit once the policy is gone. Verified by mutation -- ignoring the policy in `commit()` and re-hardcoding the exporter label each kill exactly their guarding tests. Also moves the last engine->marmot dependency out of `MlsKeyPackage`: `last_resort_key_package` is `draft-ietf-mls-extensions-10`'s component, not Marmot's, so it joins `app_components` and `safe_aad` in `ComponentsList` and `AppComponentIds` re-exposes all three. `quartz/mls/` no longer imports `quartz/marmot/` anywhere. Suite 5074 -> 5082, green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../amethyst/commons/marmot/MarmotManager.kt | 19 +- .../quartz/marmot/MarmotOutboundProcessor.kt | 1 + .../marmot/appComponents/AppComponentIds.kt | 11 +- .../CurrentProfileGroupFactory.kt | 11 +- .../marmot/groups/MarmotCapabilities.kt | 146 +++++ .../quartz/marmot/groups/MarmotGroupPolicy.kt | 268 +++++++++ .../quartz/marmot/groups/MarmotGroupViews.kt | 79 +++ .../quartz/marmot/groups/MlsGroupManager.kt | 15 +- .../protocolCore/MarmotConvergenceEngine.kt | 8 +- .../protocolCore/MlsCandidateStateEngine.kt | 2 + .../quartz/mls/components/ComponentsList.kt | 8 + .../quartz/mls/group/MlsGroup.kt | 508 ++++-------------- .../quartz/mls/group/MlsGroupPolicy.kt | 175 ++++++ .../quartz/mls/messages/MlsKeyPackage.kt | 4 +- .../quartz/marmot/MarmotMipBehaviorTest.kt | 28 +- .../CurrentProfileAuthorizationTest.kt | 9 +- .../CurrentProfileGroupFactoryTest.kt | 3 + .../marmot/groups/MarmotPolicySeamTest.kt | 115 ++++ .../marmot/groups/MlsGroupManagerTest.kt | 7 +- .../mls/group/CurrentProfileWelcomeTest.kt | 20 +- .../mls/group/MlsGroupPolicySeamTest.kt | 145 +++++ 21 files changed, 1126 insertions(+), 456 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 9992b39279..85dfc3861b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -51,6 +51,12 @@ import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotGroupSnapshot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager @@ -59,18 +65,15 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore -import com.vitorpamplona.quartz.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager -import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore -import com.vitorpamplona.quartz.mls.messages.CommitResult -import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishGate import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligation import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.marmot.protocolCore.PublishOutcome +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -87,14 +90,14 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi /** * Central coordinator for Marmot MLS group messaging. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index e20c211934..a8f2136f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.currentGroupState import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt index 5604b8f24b..29ae1ce609 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents +import com.vitorpamplona.quartz.mls.components.ComponentsList + /** * Marmot app-component ids, from the spec's `foundation/registries.md`. * @@ -39,11 +41,14 @@ package com.vitorpamplona.quartz.marmot.appComponents object AppComponentIds { // ---- upstream, draft-ietf-mls-extensions-10 ---- + // These three are the draft's, not Marmot's, so they are defined in the + // engine beside the dictionary that carries them and re-exposed here. + /** `app_components`: the supported (LeafNode) or required (GroupContext) id list. */ - const val APP_COMPONENTS = 0x0001 + const val APP_COMPONENTS = ComponentsList.APP_COMPONENTS_ID /** `safe_aad`: component-separated framing for MLS `authenticated_data`. */ - const val SAFE_AAD = 0x0002 + const val SAFE_AAD = ComponentsList.SAFE_AAD_ID /** * `last_resort_key_package`: empty-data marker in a KeyPackage's own @@ -51,7 +56,7 @@ object AppComponentIds { * MIP-era profile marked last resort with extension `0x000a`, which is now * the `self_remove` PROPOSAL type. */ - const val LAST_RESORT_KEY_PACKAGE = 0x0004 + const val LAST_RESORT_KEY_PACKAGE = ComponentsList.LAST_RESORT_KEY_PACKAGE_ID // ---- Marmot private range ---- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt index 6724c0a093..0ee1543459 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.mls.components.AppDataDictionary import com.vitorpamplona.quartz.mls.components.ComponentData @@ -161,7 +163,7 @@ object CurrentProfileGroupFactory { signingKey = leaf.signatureKeyPair.privateKey, leafSignatureKeyPair = leaf.signatureKeyPair, leafExtensions = leaf.leafExtensions, - capabilities = MlsGroup.currentProfileLeafCapabilities(), + capabilities = MarmotCapabilities.currentProfileLeaf(), keyPackageExtensions = keyPackageExtensions, ) } @@ -218,8 +220,11 @@ object CurrentProfileGroupFactory { signingKey = leaf.signatureKeyPair.privateKey, initialExtensions = listOf(dictionary.toExtension()), leafExtensions = leaf.leafExtensions, - capabilities = MlsGroup.currentProfileLeafCapabilities(), - requiredCapabilities = MlsGroup.buildCurrentProfileRequiredCapabilitiesExtension(), + // The current profile shares Marmot's authorization rules but + // advertises a different capability set, so both are named here. + policy = MarmotGroupPolicy, + capabilities = MarmotCapabilities.currentProfileLeaf(), + requiredCapabilities = MarmotCapabilities.currentProfileRequired(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt new file mode 100644 index 0000000000..a0c1ae5abd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * The MLS capability sets that identify a group as Marmot's. + * + * These used to be defaults baked into `MlsGroup.create`, which is why a + * plain RFC 9420 group could not be created at all: every group came out + * requiring `marmot_group_data`. They are unchanged, only relocated, and the + * engine now reaches them through [MarmotGroupPolicy]. + * + * Marmot has two profiles and a client must be able to read either, so both + * sets live here: + * + * - **MIP-era** — `0xF2EE` carries all group state, `self_remove` is required. + * - **current** — `app_data_dictionary` (`0x0006`) carries it as components, + * with `app_data_update` (`0x0008`) to change them. + */ +object MarmotCapabilities { + /** Marmot Group Data Extension type (MIP-01). */ + const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE + + /** + * Default MLS leaf Capabilities that advertise support for Marmot's + * required extensions and proposals so new members can join a group + * whose `required_capabilities` lists them. + */ + fun mipLeaf(): Capabilities = + Capabilities( + extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * Build an MLS `required_capabilities` extension that marks Marmot's + * mandatory interop set as required for all members (RFC 9420 §7.2): + * extensions = [marmot_group_data (0xF2EE)] + * proposals = [self_remove (0x000A)] + * credentials = [Basic (0x0001)] + */ + fun mipRequired(): Extension = + requiredCapabilities( + extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * Leaf capabilities for the current profile. + * + * RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only + * the draft `app_data_dictionary` extension and the `app_data_update` + * proposal appear — `required_capabilities` support is implicit. + * + * The legacy `0xF2EE` group-data extension is advertised alongside + * them, and that is not a hedge. A capability says "this client can + * handle it", not "this group uses it", and a group that REQUIRES + * `0xF2EE` refuses to add a leaf that does not advertise it. Without + * this line a current-profile KeyPackage would be un-addable to every + * legacy group that already exists — the exact mirror of the interop + * failure the current profile was adopted to fix. + * + * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: + * a group carrying component `0x8006` with `required_member_roles` + * naming `receive` refuses a leaf that does not advertise it. The + * reference client puts exactly that policy into EVERY group it + * creates, so without this line an Amethyst KeyPackage cannot be + * invited into one at all. + * + * We stop at receive. `send` and `fanout` are not here because we do + * not originate previews from the app, and a capability is a standing + * promise rather than a hedge. + */ + fun currentProfileLeaf(): Capabilities = + Capabilities( + extensions = + listOf( + AppDataDictionary.EXTENSION_TYPE, + MarmotGroupData.EXTENSION_ID_INT, + AgentTextStreamRoles.RECEIVE_CAPABILITY, + ), + proposals = listOf(MlsGroup.APP_DATA_UPDATE_PROPOSAL_TYPE, MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * `required_capabilities` for a new current-profile group: extension + * `0x0006` and proposal `0x0008`. + * + * The Marmot components a group requires are negotiated in the + * upstream `app_components` component INSIDE the dictionary, not here — + * MLS `RequiredCapabilities` carries only MLS-level primitives. + */ + fun currentProfileRequired(): Extension = + requiredCapabilities( + extensions = listOf(AppDataDictionary.EXTENSION_TYPE), + proposals = listOf(MlsGroup.APP_DATA_UPDATE_PROPOSAL_TYPE), + ) + + /** Encodes an RFC 9420 §7.2 `required_capabilities` extension over Basic credentials. */ + private fun requiredCapabilities( + extensions: List, + proposals: List, + ): Extension { + val writer = TlsWriter() + // extensions: uint16 each + val exts = TlsWriter() + extensions.forEach { exts.putUint16(it) } + writer.putOpaqueVarInt(exts.toByteArray()) + // proposals: uint16 each + val props = TlsWriter() + proposals.forEach { props.putUint16(it) } + writer.putOpaqueVarInt(props.toByteArray()) + // credentials: uint16 each + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt new file mode 100644 index 0000000000..f7e758771c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.GroupView +import com.vitorpamplona.quartz.mls.group.MlsExporterLabel +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.PendingProposal +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * Marmot's authorization rules (MIP-01 and MIP-03), as an [MlsGroupPolicy]. + * + * These used to live inside `MlsGroup`, which meant the RFC 9420 engine knew + * the word "admin" — a concept RFC 9420 does not have. They are unchanged + * here; only where they run has moved. + * + * Stateless, so one instance serves every group. + */ +object MarmotGroupPolicy : MlsGroupPolicy { + /** + * The MIP-era leaf set. A current-profile group names + * [MarmotCapabilities.currentProfileLeaf] explicitly instead: the two + * profiles differ in what they advertise but share these authorization + * rules, so the policy carries the older default and the factory that + * knows it is building a current-profile group overrides it. + */ + override val defaultLeafCapabilities: Capabilities get() = MarmotCapabilities.mipLeaf() + + override val defaultRequiredCapabilities: Extension get() = MarmotCapabilities.mipRequired() + + /** + * `0xF2EE`. The current profile's `app_data_dictionary` carrier is already + * in the engine's own set — it is a draft MLS extension, not a Marmot one. + */ + override val knownExtensionTypes: Set get() = setOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE) + + /** + * `MLS-Exporter("marmot", "group-event", 32)` — the outer + * ChaCha20-Poly1305 key for a kind:445 GroupEvent. A commit must be sealed + * under the PRE-commit epoch so members still at epoch N can open it. + */ + override val commitExporter: MlsExporterLabel + get() = MlsExporterLabel("marmot", "group-event".encodeToByteArray(), 32) + + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + enforceAuthorizedProposalSet(group, proposals, committerLeafIndex) + enforceNoAdminDepletion(group, proposals) + } + + override fun authorizeSelfRemove(group: GroupView) { + check(!isLocalAdmin(group)) { + "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" + } + } + + override fun validateJoin(group: GroupView) { + requireAgentTextStreamRoles(group) + } + + /** + * The admin set named by [extensions], preferring the current profile. + * + * Decodes ONLY the admin policy, never the whole component set. Authorization + * must not depend on the validity of components it does not read: a + * malformed group profile is a defect worth surfacing where the profile is + * used, but it must not make the group un-committable by taking the admin + * check down with it. + * + * Reads whichever profile this group is on: the current profile's + * `marmot.group.admin-policy.v1` component (`0x8003`) when present, + * otherwise MIP-01's `admin_pubkeys` field inside `marmot_group_data` + * (`0xF2EE`). Empty means the group names no admins at all, which happens + * during bootstrap and in groups that carry neither. + */ + fun adminIdentitiesIn(extensions: List): Set { + val policyBytes = AppDataDictionary.fromExtensionsOrEmpty(extensions)[AdminPolicyV1.COMPONENT_ID] + if (policyBytes != null) return AdminPolicyV1.decode(policyBytes).adminHexKeys.toSet() + return MarmotGroupData + .fromExtensions(extensions) + ?.adminPubkeys + ?.toSet() + .orEmpty() + } + + /** True if the member at [leafIndex] is an ACTIVE admin: named in the admin set and still holding a leaf. */ + fun isLeafAdmin( + group: GroupView, + leafIndex: Int, + ): Boolean { + val id = group.memberIdentityHex(leafIndex) ?: return false + return id in adminIdentitiesIn(group.extensions) + } + + /** True if the local member is an active admin. */ + fun isLocalAdmin(group: GroupView): Boolean = isLeafAdmin(group, group.myLeafIndex) + + /** + * MIP-03: a non-admin may commit only a single self-Update, or a set made + * entirely of their own SelfRemoves. + * + * The "self-only" rule is checked against the committer; when the committer + * is an admin the rule is skipped entirely so admin-folded inbound proposals + * (e.g. another member's `SelfRemove` referenced by an admin's GCE commit) + * are accepted. + */ + internal fun enforceAuthorizedProposalSet( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + if (proposals.isEmpty()) return + // Reads whichever profile the group is on: the admin-policy component + // (0x8003) for current-profile groups, `marmot_group_data` (0xF2EE) + // for legacy ones. An empty set means bootstrap — no admins named yet — + // and the gate stays open, mirroring MlsGroupManager.updateGroupExtensions. + val admins = adminIdentitiesIn(group.extensions) + if (admins.isEmpty() || isLeafAdmin(group, committerLeafIndex)) return + + val allSelfRemove = + proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == committerLeafIndex } + if (allSelfRemove) return + + val singleSelfUpdate = + proposals.size == 1 && + proposals[0].proposal is Proposal.Update && + proposals[0].senderLeafIndex == committerLeafIndex + if (singleSelfUpdate) return + + throw IllegalStateException( + "MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " + + "proposals; got ${proposals.map { it.proposal::class.simpleName }} from leaf $committerLeafIndex", + ) + } + + /** + * Reject any commit that would leave the group without at least one member + * still listed in `admin_pubkeys` (MIP-03 admin depletion guard). + * + * We simulate the post-commit member set and the post-commit `admin_pubkeys` + * list, then require a non-empty intersection. The guard is only active + * once the group has a configured admin set — it does not kick in during + * bootstrap before any admin is named. + */ + internal fun enforceNoAdminDepletion( + group: GroupView, + proposals: List, + ) { + val currentAdmins = adminIdentitiesIn(group.extensions) + if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete. + + // Resolve the effective admin list after this commit. Three carriers can + // change it, and they are checked in the order the commit applies them: + // an AppDataUpdate on 0x8003 (current profile), then a + // GroupContextExtensions proposal replacing the whole extension list + // (either profile). AppDataUpdate is resolved last because + // `applyAppDataUpdateProposals` runs after the rest of the list. + val gce = + proposals + .asSequence() + .map { it.proposal } + .filterIsInstance() + .lastOrNull() + val extensionsAfterGce = gce?.extensions ?: group.extensions + + val adminUpdate = + proposals + .asSequence() + .map { it.proposal } + .filterIsInstance() + .lastOrNull { it.componentId == AdminPolicyV1.COMPONENT_ID } + + val adminSet = + when (val operation = adminUpdate?.operation) { + is Proposal.AppDataUpdate.Operation.Update -> + AdminPolicyV1.decode(operation.data).adminHexKeys.toSet() + + // Removing the admin policy is never valid — it is the sole + // admin authority for the group's lifetime — so an empty set + // here trips the depletion check below, which is the outcome + // we want. + Proposal.AppDataUpdate.Operation.Remove -> emptySet() + + null -> adminIdentitiesIn(extensionsAfterGce) + } + check(adminSet.isNotEmpty()) { + "commit would leave the group with no admins (admin depletion)" + } + + // Compute which leaves remain after applying Removes/SelfRemoves. + val removedLeaves = mutableSetOf() + for (pending in proposals) { + when (val p = pending.proposal) { + is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex) + is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex) + else -> Unit + } + } + + val remainingAdminIdentities = mutableSetOf() + for (i in 0 until group.leafCount) { + if (i in removedLeaves) continue + val id = group.memberIdentityHex(i) ?: continue + if (id in adminSet) remainingAdminIdentities.add(id) + } + + check(remainingAdminIdentities.isNotEmpty()) { + "MIP-03: commit would leave the group without any admin members" + } + } + + /** + * Enforce the `0x8006` component's `required_member_roles` mask over + * the joining tree. + * + * A group carrying the agent-text-stream component requires each named + * role as an MLS leaf capability (`0xF2D1` receive, `0xF2D2` send, + * `0xF2D4` fanout). Advertising the component id alone is not enough — + * that only says "understands the component"; the role capability says + * "can actually do this". + */ + private fun requireAgentTextStreamRoles(group: GroupView) { + val policy = + AppDataDictionary + .fromExtensionsOrEmpty(group.extensions)[AgentTextStreamQuicPolicyV1.COMPONENT_ID] + ?.let { AgentTextStreamQuicPolicyV1.decode(it) } ?: return + val required = policy.requiredRoleCapabilities() + if (required.isEmpty()) return + + val myLeaf = group.leafCapabilities(group.myLeafIndex) + requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" } + val missing = required.filterNot { myLeaf.extensions.contains(it) } + require(missing.isEmpty()) { + "Joiner does not advertise agent text stream roles this group requires: " + + missing.joinToString { AppComponentIds.toHex(it) } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt new file mode 100644 index 0000000000..db32823fd2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +// Marmot's reads of an MlsGroup's GroupContext. +// +// These were methods on MlsGroup itself, which put MIP-01 extension parsing and +// Nostr routing ids inside an RFC 9420 engine. None of them needs the group's +// internals — every one goes through the public extensions / exporterSecret / +// view() surface — so they live here as extensions, and the engine no longer +// knows Marmot exists. + +/** Parsed Marmot Group Data Extension from the current GroupContext, or null. */ +fun MlsGroup.currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(extensions) + +/** The current profile's component view of this GroupContext. */ +fun MlsGroup.currentGroupState(): MarmotGroupState = MarmotGroupState.fromExtensions(extensions) + +/** + * The `nostr_group_id` this group routes kind-445 traffic under, from + * whichever profile the group is actually using. + * + * A current-profile group carries it in the `marmot.transport.nostr.routing.v1` + * component (`0x8004`); a legacy group carries it inside the monolithic + * `0xF2EE` extension. Reading only the legacy one leaves us unable to join + * any group a current-profile client created — the routing id is required + * to subscribe at all, so the failure is total rather than partial. + */ +fun MlsGroup.currentNostrGroupId(): HexKey? = + currentGroupState().routing?.nostrGroupIdHex + ?: currentMarmotData()?.nostrGroupId + +/** + * The group's configured admin account identities, as lowercase hex. + * + * Empty means the group names no admins at all, which happens during + * bootstrap and in groups that carry neither carrier. + */ +fun MlsGroup.currentAdminIdentities(): Set = MarmotGroupPolicy.adminIdentitiesIn(extensions) + +/** True if the local member is an active admin. */ +fun MlsGroup.isLocalAdmin(): Boolean = MarmotGroupPolicy.isLocalAdmin(view()) + +/** + * `MLS-Exporter("marmot", "agent-text-stream-quic", 32)` — the secret every + * member of this epoch derives per-stream record keys from. Per-stream and + * per-record separation is entirely in the HKDF key context, so this one + * secret covers every stream in the epoch. + */ +fun MlsGroup.agentTextStreamSecret(): ByteArray = + exporterSecret( + AgentTextStreamCrypto.EXPORTER_LABEL, + AgentTextStreamCrypto.EXPORTER_CONTEXT, + AgentTextStreamCrypto.SECRET_LENGTH, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt index 4bd4334f40..59965b1147 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.marmot.groups import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1 import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.mls.components.ComponentsList @@ -141,7 +144,7 @@ class MlsGroupManager( continue } val state = MlsGroupState.decodeTls(stateBytes) - groups[nostrGroupId] = MlsGroup.restore(state) + groups[nostrGroupId] = MlsGroup.restore(state, MarmotGroupPolicy) Log.d(TAG) { "restoreAll(): restored group $nostrGroupId (${stateBytes.size} bytes)" } // Restore retained epochs @@ -214,7 +217,7 @@ class MlsGroupManager( if (!changed) return@withLock val outgoing = current?.retainedSecrets() - groups[nostrGroupId] = MlsGroup.restore(state) + groups[nostrGroupId] = MlsGroup.restore(state, MarmotGroupPolicy) // Retain by outgoing epoch even when the epoch NUMBER is unchanged: a // same-epoch rewind swaps one epoch-N state for a different one, and // the abandoned N still has traffic addressed to it. @@ -278,7 +281,7 @@ class MlsGroupManager( ): MlsGroup = mutex.withLock { Log.d(TAG) { "createGroup($nostrGroupId): creating new MLS group" } - val group = MlsGroup.create(identity, signingKey, initialExtensions) + val group = MlsGroup.create(identity, signingKey, initialExtensions, policy = MarmotGroupPolicy) groups[nostrGroupId] = group persistGroup(nostrGroupId) Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" } @@ -309,7 +312,7 @@ class MlsGroupManager( hintNostrGroupId: HexKey? = null, ): Pair = mutex.withLock { - val group = MlsGroup.processWelcome(welcomeBytes, bundle) + val group = MlsGroup.processWelcome(welcomeBytes, bundle, MarmotGroupPolicy) val derivedId = group.currentNostrGroupId() @@ -349,7 +352,7 @@ class MlsGroupManager( signingKey: ByteArray? = null, ): ExternalJoinResult = mutex.withLock { - val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey) + val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey, policy = MarmotGroupPolicy) groups[nostrGroupId] = result.group persistGroup(nostrGroupId) result @@ -413,7 +416,7 @@ class MlsGroupManager( mutex.withLock { val live = requireGroup(nostrGroupId) val priorState = live.saveState() - val clone = MlsGroup.restore(priorState) + val clone = MlsGroup.restore(priorState, MarmotGroupPolicy) val result = prepare(clone) StagedCommit(result, priorState, clone.saveState()) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt index b6f3411c0a..f67bbd93b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.currentGroupState import com.vitorpamplona.quartz.mls.framing.ContentType import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.mls.group.MlsGroupState @@ -421,7 +423,7 @@ class MarmotConvergenceEngine( mutex.withLock { contexts[groupId]?.candidateStates?.values?.mapNotNull { state -> try { - MlsGroup.restore(state).exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret("marmot", "group-event".encodeToByteArray(), 32) } catch (_: Exception) { null } @@ -451,7 +453,7 @@ class MarmotConvergenceEngine( // A clone per attempt: decrypting advances the secret // tree, and a candidate state gets tried by every // message that failed canonically. - MlsGroup.restore(state).decrypt(mlsBytes) + MlsGroup.restore(state, MarmotGroupPolicy).decrypt(mlsBytes) } catch (_: Exception) { continue } @@ -460,7 +462,7 @@ class MarmotConvergenceEngine( stateId = stateId, epoch = decrypted.epoch, senderLeafIndex = decrypted.senderLeafIndex, - senderAccount = MlsGroup.restore(state).memberIdentityHex(decrypted.senderLeafIndex), + senderAccount = MlsGroup.restore(state, MarmotGroupPolicy).memberIdentityHex(decrypted.senderLeafIndex), content = decrypted.content, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt index c8bfcadedf..fde5dbb09d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.framing.ContentType import com.vitorpamplona.quartz.mls.framing.MlsMessage diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt index 1bd4b1e5e6..0f2ea86fe9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt @@ -113,6 +113,14 @@ object ComponentsList { /** Component id of the upstream `safe_aad` list. */ const val SAFE_AAD_ID = 0x0002 + /** + * `last_resort_key_package`: empty-data marker in a KeyPackage's own + * dictionary. Note this is a component, NOT an MLS extension type — the + * MIP-era profile marked last resort with extension `0x000a`, which is now + * the `self_remove` PROPOSAL type. + */ + const val LAST_RESORT_KEY_PACKAGE_ID = 0x0004 + /** The supported/required id list carried by [dictionary], or empty when absent. */ fun supportedOrRequired(dictionary: AppDataDictionary): List = dictionary[APP_COMPONENTS_ID]?.let { decode(it) } ?: emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index d2a53ebdd9..01a3243586 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -20,13 +20,6 @@ */ package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.mls.components.AppDataDictionary @@ -70,7 +63,6 @@ import com.vitorpamplona.quartz.mls.tree.Lifetime import com.vitorpamplona.quartz.mls.tree.PathSecretAndKey import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.mls.tree.UpdatePathNode -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.mac.MacInstance @@ -104,8 +96,8 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance * // Decrypt application message * val decrypted = group.decrypt(encrypted) * - * // Export key for Marmot outer encryption - * val key = group.exporterSecret("marmot", "group-event", 32) + * // Export key for a binding's own outer encryption + * val key = group.exporterSecret("myapp", "group-event".encodeToByteArray(), 32) * ``` */ private fun constantTimeEquals( @@ -150,6 +142,12 @@ class MlsGroup private constructor( * for us". */ private val pathPrivateKeys: MutableMap = mutableMapOf(), + /** + * The application's authorization rules. See [MlsGroupPolicy]: RFC 9420 + * itself places no limit on who may commit what, so the default allows + * everything the protocol allows and a binding supplies its own. + */ + private val policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, ) { val groupId: ByteArray get() = groupContext.groupId val epoch: Long get() = groupContext.epoch @@ -168,6 +166,22 @@ class MlsGroup private constructor( */ fun isLocalMember(): Boolean = myLeafIndex < tree.leafCount && tree.getLeaf(myLeafIndex) != null + /** + * The read-only projection this group hands to its [MlsGroupPolicy]. + * + * Public because a binding's own checks want the same view the engine + * gives the policy — Marmot's "only admins may change group extensions" + * gate runs in `MlsGroupManager`, before any commit is staged. + */ + fun view(): GroupView = + GroupView( + extensions = groupContext.extensions, + leafCount = tree.leafCount, + myLeafIndex = myLeafIndex, + identityAt = { memberIdentityHex(it) }, + capabilitiesAt = { tree.getLeaf(it)?.capabilities }, + ) + /** * Read-only snapshot of the staged-proposal pool. Exposed at module * scope so tests can inspect what `proposeAdd` / `proposeRemove` / @@ -207,7 +221,7 @@ class MlsGroup private constructor( return w.toByteArray() } - // --- Marmot admin helpers (MIP-01 / MIP-03) --- + // --- Member identity --- /** Raw BasicCredential identity bytes of the member at the given leaf, or null. */ fun memberIdentity(leafIndex: Int): ByteArray? = (tree.getLeaf(leafIndex)?.credential as? Credential.Basic)?.identity @@ -218,81 +232,14 @@ class MlsGroup private constructor( /** Lowercase hex of the local member's BasicCredential identity, or null. */ fun myIdentityHex(): String? = memberIdentityHex(myLeafIndex) - /** Parsed Marmot Group Data Extension from the current GroupContext, or null. */ - fun currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(groupContext.extensions) - - /** The current profile's component view of this GroupContext. */ - fun currentGroupState(): MarmotGroupState = MarmotGroupState.fromExtensions(groupContext.extensions) - - /** - * The `nostr_group_id` this group routes kind-445 traffic under, from - * whichever profile the group is actually using. - * - * A current-profile group carries it in the `marmot.transport.nostr.routing.v1` - * component (`0x8004`); a legacy group carries it inside the monolithic - * `0xF2EE` extension. Reading only the legacy one leaves us unable to join - * any group a current-profile client created — the routing id is required - * to subscribe at all, so the failure is total rather than partial. - */ - fun currentNostrGroupId(): HexKey? = - currentGroupState().routing?.nostrGroupIdHex - ?: currentMarmotData()?.nostrGroupId - - /** - * The group's configured admin account identities, as lowercase hex. - * - * Reads whichever profile this group is on: the current profile's - * `marmot.group.admin-policy.v1` component (`0x8003`) when present, - * otherwise MIP-01's `admin_pubkeys` field inside `marmot_group_data` - * (`0xF2EE`). Empty means the group names no admins at all, which happens - * during bootstrap and in groups that carry neither. - * - * The current profile is checked first because a group can only be one of - * the two — MDK rejects a group that requires both proof profiles — and a - * current-profile group is the one whose authorization we must not skip. - */ - fun currentAdminIdentities(): Set = adminIdentitiesIn(groupContext.extensions) - - /** - * The admin set named by [extensions], preferring the current profile. - * - * Decodes ONLY the admin policy, never the whole component set. Authorization - * must not depend on the validity of components it does not read: a - * malformed group profile is a defect worth surfacing where the profile is - * used, but it must not make the group un-committable by taking the admin - * check down with it. - */ - private fun adminIdentitiesIn(extensions: List): Set { - val policyBytes = AppDataDictionary.fromExtensionsOrEmpty(extensions)[AdminPolicyV1.COMPONENT_ID] - if (policyBytes != null) return AdminPolicyV1.decode(policyBytes).adminHexKeys.toSet() - return MarmotGroupData - .fromExtensions(extensions) - ?.adminPubkeys - ?.toSet() - .orEmpty() - } - /** * Account identities holding at least one current member leaf, as hex. * - * Admin authority is per ACCOUNT, not per leaf: a multi-device account - * shares one admin entry across all of its leaves. + * A set of ACCOUNTS, not leaves: one account may hold several leaves (one + * per device), and every binding that asks this question means the account. */ fun currentMemberIdentities(): Set = (0 until tree.leafCount).mapNotNullTo(mutableSetOf()) { memberIdentityHex(it) } - /** True if the local member is an active admin. */ - fun isLocalAdmin(): Boolean = isLeafAdmin(myLeafIndex) - - /** - * True if the member at [leafIndex] is an ACTIVE admin: listed in the - * group's admin set and still holding a leaf. The leaf lookup satisfies - * the second half by construction. - */ - fun isLeafAdmin(leafIndex: Int): Boolean { - val id = memberIdentityHex(leafIndex) ?: return false - return id in currentAdminIdentities() - } - // --- State Persistence --- /** @@ -431,7 +378,7 @@ class MlsGroup private constructor( */ leafSignatureKeyPair: Ed25519KeyPair? = null, leafExtensions: List = emptyList(), - capabilities: Capabilities = marmotLeafCapabilities(), + capabilities: Capabilities = policy.defaultLeafCapabilities, keyPackageExtensions: List = emptyList(), ): KeyPackageBundle { val initKp = X25519.generateKeyPair() @@ -490,15 +437,14 @@ class MlsGroup private constructor( /** * Create a SelfRemove proposal. * - * Per MIP-01/MIP-03, members listed in `admin_pubkeys` MUST NOT issue a - * SelfRemove — they have to first publish a GroupContextExtensions proposal - * removing themselves from the admin list (self-demotion). This guard - * enforces that rule at the local sender. + * Gated by [MlsGroupPolicy.authorizeSelfRemove], because a binding may + * restrict who can leave unilaterally — Marmot makes an admin self-demote + * through a GroupContextExtensions proposal first. Catching it here rather + * than on arrival turns a commit every peer would refuse into a local + * error. */ fun proposeSelfRemove(): Proposal.SelfRemove { - check(!isLocalAdmin()) { - "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" - } + policy.authorizeSelfRemove(view()) val proposal = Proposal.SelfRemove() pendingProposals.add(PendingProposal(proposal, myLeafIndex)) return proposal @@ -537,7 +483,7 @@ class MlsGroup private constructor( signingKey = newSigKp.privateKey, groupId = groupId, leafIndex = myLeafIndex, - capabilities = currentLeaf?.capabilities ?: marmotLeafCapabilities(), + capabilities = currentLeaf?.capabilities ?: policy.defaultLeafCapabilities, leafExtensions = currentLeaf?.extensions ?: emptyList(), ) @@ -629,25 +575,16 @@ class MlsGroup private constructor( fun commit(): CommitResult { val proposals = pendingProposals.toList() - // --- MIP-03 authorization gate ----------------------------------------- - // - // Non-admin senders may only issue one of two restricted commit shapes: - // (a) a single self-Update targeting their own leaf, or - // (b) one or more SelfRemove proposals, all by themselves (no mixing). - // - // Admins may commit any proposal type. - enforceAuthorizedProposalSet(proposals) - - // Reject commits that would leave the group without a usable admin - // (i.e. no remaining member appears in the post-commit admin list). - enforceNoAdminDepletion(proposals) + // The application's gate on who may commit what. RFC 9420 has none of + // its own, so a group with the default policy accepts any valid set. + policy.authorizeCommit(view(), proposals, myLeafIndex) // Capture the pre-commit exporter secret BEFORE any mutation. // Publishers of the outbound kind:445 MUST outer-encrypt with this // key (epoch N) so that other existing members at epoch N can decrypt // and process the commit. See CommitResult.preCommitExporterSecret. val preCommitExporterSecret = - exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + policy.commitExporter?.let { exporterSecret(it.label, it.context, it.length) } ?: ByteArray(0) // Snapshot the pre-proposal extensions. GroupContextExtensions proposals // mutate `groupContext.extensions` the moment they're applied, but @@ -842,7 +779,7 @@ class MlsGroup private constructor( groupId = groupId, leafIndex = myLeafIndex, parentHash = leafParentHash, - capabilities = previousLeaf?.capabilities ?: marmotLeafCapabilities(), + capabilities = previousLeaf?.capabilities ?: policy.defaultLeafCapabilities, leafExtensions = previousLeaf?.extensions ?: emptyList(), ) encryptionPrivateKey = newEncKp.privateKey @@ -1694,10 +1631,9 @@ class MlsGroup private constructor( } // Resolve proposal references against our pending pool BEFORE - // applying anything, so MIP-03 authorization can run on a static - // snapshot of (proposal, original-sender-leaf) pairs and so the - // depletion guard can simulate the post-commit tree shape from the - // pre-commit state. + // applying anything, so the policy sees a static snapshot of + // (proposal, original-sender-leaf) pairs and can simulate the + // post-commit shape from pre-commit state. val resolvedPending = mutableListOf() for (proposalOrRef in commit.proposals) { when (proposalOrRef) { @@ -1731,17 +1667,16 @@ class MlsGroup private constructor( } } - // MIP-03 authorization & admin-depletion gates on inbound commits - // (mirror what `commit()` enforces locally — without these a peer - // could send us a non-admin GCE rename, a non-admin Remove, or a - // commit that empties `admin_pubkeys` and we'd silently apply it). + // The policy's gate on inbound commits, mirroring what `commit()` + // enforces locally. Without it a peer could send us anything its own + // copy of the rules would have refused and we would silently apply + // it — authorization has to run on both ends or it runs on neither. // External commits get a pass: the sender doesn't have a leaf yet, // so the admin lookup is moot, and an external joiner can't include // arbitrary proposals — only Add/Remove/PSK/ExternalInit per // RFC 9420 §12.4.3.2. if (!isExternalCommit) { - enforceAuthorizedProposalSet(resolvedPending, committerLeafIndex = senderLeafIndex) - enforceNoAdminDepletion(resolvedPending) + policy.authorizeCommit(view(), resolvedPending, senderLeafIndex) } // Apply the resolved proposals. Matches the committer's order: apply @@ -2065,9 +2000,10 @@ class MlsGroup private constructor( /** * MLS-Exporter function for deriving application-specific keys. * - * Marmot uses: - * exporterSecret("marmot", "group-event".toByteArray(), 32) - * to derive the outer ChaCha20-Poly1305 key for GroupEvents. + * Marmot, for instance, derives the outer ChaCha20-Poly1305 key for its + * GroupEvents with label "marmot" and context "group-event" — see + * [MlsGroupPolicy.commitExporter], which is how the engine reaches it + * without naming any one binding. */ fun exporterSecret( label: String, @@ -2075,19 +2011,6 @@ class MlsGroup private constructor( length: Int, ): ByteArray = KeySchedule.mlsExporter(epochSecrets.exporterSecret, label, context, length) - /** - * `MLS-Exporter("marmot", "agent-text-stream-quic", 32)` — the secret every - * member of this epoch derives per-stream record keys from. Per-stream and - * per-record separation is entirely in the HKDF key context, so this one - * secret covers every stream in the epoch. - */ - fun agentTextStreamSecret(): ByteArray = - exporterSecret( - AgentTextStreamCrypto.EXPORTER_LABEL, - AgentTextStreamCrypto.EXPORTER_CONTEXT, - AgentTextStreamCrypto.SECRET_LENGTH, - ) - // --- External Join Support (RFC 9420 Section 8.3, 12.4.3.2) --- /** @@ -2524,8 +2447,7 @@ class MlsGroup private constructor( fun isCommitAuthorized(pubMsg: PublicMessage): Boolean { val proposals = resolveCommitProposals(pubMsg) ?: return false return try { - enforceAuthorizedProposalSet(proposals, committerLeafIndex = pubMsg.sender.leafIndex) - enforceNoAdminDepletion(proposals) + policy.authorizeCommit(view(), proposals, pubMsg.sender.leafIndex) true } catch (_: Exception) { false @@ -2664,126 +2586,6 @@ class MlsGroup private constructor( return tree.addLeaf(leafNode) } - /** - * MIP-03 authorization gate. - * - * Once the group has at least one admin configured in `admin_pubkeys`, - * non-admin senders may only issue: - * - a single self-Update proposal, or - * - one-or-more SelfRemove proposals authored by the committer. - * - * Admins may commit any proposal type. Before any admin is configured - * (group bootstrap) the check is relaxed, mirroring the bootstrap policy - * in [MlsGroupManager.updateGroupExtensions]. - * - * [committerLeafIndex] is the leaf that signed the commit — `myLeafIndex` - * for our own outbound commits, `pubMsg.sender.leafIndex` for inbound - * commits. The "self-only" rule is checked against the committer; when - * the committer is an admin the rule is skipped entirely so admin-folded - * inbound proposals (e.g. another member's `SelfRemove` referenced by - * an admin's GCE commit) are accepted. - */ - internal fun enforceAuthorizedProposalSet( - proposals: List, - committerLeafIndex: Int = myLeafIndex, - ) { - if (proposals.isEmpty()) return - // Reads whichever profile the group is on: the admin-policy component - // (0x8003) for current-profile groups, `marmot_group_data` (0xF2EE) - // for legacy ones. An empty set means bootstrap — no admins named yet — - // and the gate stays open, mirroring MlsGroupManager.updateGroupExtensions. - val admins = currentAdminIdentities() - if (admins.isEmpty() || isLeafAdmin(committerLeafIndex)) return - - val allSelfRemove = - proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == committerLeafIndex } - if (allSelfRemove) return - - val singleSelfUpdate = - proposals.size == 1 && - proposals[0].proposal is Proposal.Update && - proposals[0].senderLeafIndex == committerLeafIndex - if (singleSelfUpdate) return - - throw IllegalStateException( - "MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " + - "proposals; got ${proposals.map { it.proposal::class.simpleName }} from leaf $committerLeafIndex", - ) - } - - /** - * Reject any commit that would leave the group without at least one member - * still listed in `admin_pubkeys` (MIP-03 admin depletion guard). - * - * We simulate the post-commit member set and the post-commit `admin_pubkeys` - * list, then require a non-empty intersection. The guard is only active - * once the group has a configured admin set — it does not kick in during - * bootstrap before any admin is named. - */ - internal fun enforceNoAdminDepletion(proposals: List) { - val currentAdmins = currentAdminIdentities() - if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete. - - // Resolve the effective admin list after this commit. Three carriers can - // change it, and they are checked in the order the commit applies them: - // an AppDataUpdate on 0x8003 (current profile), then a - // GroupContextExtensions proposal replacing the whole extension list - // (either profile). AppDataUpdate is resolved last because - // `applyAppDataUpdateProposals` runs after the rest of the list. - val gce = - proposals - .asSequence() - .map { it.proposal } - .filterIsInstance() - .lastOrNull() - val extensionsAfterGce = gce?.extensions ?: groupContext.extensions - - val adminUpdate = - proposals - .asSequence() - .map { it.proposal } - .filterIsInstance() - .lastOrNull { it.componentId == AdminPolicyV1.COMPONENT_ID } - - val adminSet = - when (val operation = adminUpdate?.operation) { - is Proposal.AppDataUpdate.Operation.Update -> - AdminPolicyV1.decode(operation.data).adminHexKeys.toSet() - - // Removing the admin policy is never valid — it is the sole - // admin authority for the group's lifetime — so an empty set - // here trips the depletion check below, which is the outcome - // we want. - Proposal.AppDataUpdate.Operation.Remove -> emptySet() - - null -> adminIdentitiesIn(extensionsAfterGce) - } - check(adminSet.isNotEmpty()) { - "commit would leave the group with no admins (admin depletion)" - } - - // Compute which leaves remain after applying Removes/SelfRemoves. - val removedLeaves = mutableSetOf() - for (pending in proposals) { - when (val p = pending.proposal) { - is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex) - is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex) - else -> Unit - } - } - - val remainingAdminIdentities = mutableSetOf() - for (i in 0 until tree.leafCount) { - if (i in removedLeaves) continue - val id = memberIdentityHex(i) ?: continue - if (id in adminSet) remainingAdminIdentities.add(id) - } - - check(remainingAdminIdentities.isNotEmpty()) { - "MIP-03: commit would leave the group without any admin members" - } - } - private fun applyProposal( proposal: Proposal, senderLeafIndex: Int, @@ -2824,7 +2626,7 @@ class MlsGroup private constructor( is Proposal.GroupContextExtensions -> { // Validate extension types are supported (RFC 9420 Section 12.1.7) for (ext in proposal.extensions) { - require(ext.extensionType in KNOWN_EXTENSION_TYPES) { + require(ext.extensionType in KNOWN_EXTENSION_TYPES || ext.extensionType in policy.knownExtensionTypes) { "Unsupported extension type: ${ext.extensionType}" } } @@ -3208,7 +3010,7 @@ class MlsGroup private constructor( // (0x0002 is ratchet_tree — putting it here makes GroupContext // unreadable to OpenMLS/MDK, which type-validates extensions by // context.) - private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003 + const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003 // RFC 9420 §13.3 IANA registry: 0x0004 is external_pub. // (0x0003 is required_capabilities — using it here makes @@ -3217,10 +3019,10 @@ class MlsGroup private constructor( private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004 /** MLS self_remove proposal type (MIP-00 / MIP-03). */ - private const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A + const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A /** MLS extensions draft `app_data_update` proposal type. */ - private const val APP_DATA_UPDATE_PROPOSAL_TYPE = 0x0008 + const val APP_DATA_UPDATE_PROPOSAL_TYPE = 0x0008 /** How far back a fresh KeyPackage LeafNode's `not_before` is set. */ private const val LIFETIME_SKEW_SECONDS = 3_600L @@ -3232,50 +3034,24 @@ class MlsGroup private constructor( */ private const val LIFETIME_SPAN_SECONDS = 84L * 24 * 60 * 60 - /** Marmot Group Data Extension type (MIP-01). */ - private const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE - - /** Known extension types that this implementation accepts. */ + /** + * Extension types RFC 9420 and the drafts we implement define. + * + * A binding's own types come from [MlsGroupPolicy.knownExtensionTypes] + * and are unioned with this at the point of use. + */ private val KNOWN_EXTENSION_TYPES = setOf( RATCHET_TREE_EXTENSION_TYPE, REQUIRED_CAPABILITIES_EXTENSION_TYPE, EXTERNAL_PUB_EXTENSION_TYPE, EXTERNAL_SENDERS_EXTENSION_TYPE, - MARMOT_GROUP_DATA_EXTENSION_TYPE, // The current profile's carrier for all app-owned group state. // A group can arrive at one either by being created with it or // by a GroupContextExtensions proposal that installs it. AppDataDictionary.EXTENSION_TYPE, ) - /** - * Build an MLS `required_capabilities` extension that marks Marmot's - * mandatory interop set as required for all members (RFC 9420 §7.2): - * extensions = [marmot_group_data (0xF2EE)] - * proposals = [self_remove (0x000A)] - * credentials = [Basic (0x0001)] - */ - private fun buildMarmotRequiredCapabilitiesExtension(): Extension { - val writer = TlsWriter() - // extensions: uint16 each - val exts = TlsWriter() - exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE) - writer.putOpaqueVarInt(exts.toByteArray()) - // proposals: uint16 each - val props = TlsWriter() - props.putUint16(SELF_REMOVE_PROPOSAL_TYPE) - writer.putOpaqueVarInt(props.toByteArray()) - // credentials: uint16 each - val creds = TlsWriter() - creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) - writer.putOpaqueVarInt(creds.toByteArray()) - return Extension( - extensionType = REQUIRED_CAPABILITIES_EXTENSION_TYPE, - extensionData = writer.toByteArray(), - ) - } - /** * Parsed view of the RFC 9420 §7.2 `required_capabilities` extension. * @@ -3427,107 +3203,6 @@ class MlsGroup private constructor( return null } - /** - * Default MLS leaf Capabilities that advertise support for Marmot's - * required extensions and proposals so new members can join a group - * whose `required_capabilities` lists them. - */ - private fun marmotLeafCapabilities(): Capabilities = - Capabilities( - extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), - proposals = listOf(SELF_REMOVE_PROPOSAL_TYPE), - ) - - /** - * Enforce the `0x8006` component's `required_member_roles` mask over - * the joining tree. - * - * A group carrying the agent-text-stream component requires each named - * role as an MLS leaf capability (`0xF2D1` receive, `0xF2D2` send, - * `0xF2D4` fanout). Advertising the component id alone is not enough — - * that only says "understands the component"; the role capability says - * "can actually do this". - */ - private fun requireAgentTextStreamRoles( - extensions: List, - tree: RatchetTree, - myLeafIndex: Int, - ) { - val policy = - AppDataDictionary - .fromExtensionsOrEmpty(extensions)[AgentTextStreamQuicPolicyV1.COMPONENT_ID] - ?.let { AgentTextStreamQuicPolicyV1.decode(it) } ?: return - val required = policy.requiredRoleCapabilities() - if (required.isEmpty()) return - - val myLeaf = tree.getLeaf(myLeafIndex) - requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" } - val missing = required.filterNot { myLeaf.capabilities.extensions.contains(it) } - require(missing.isEmpty()) { - "Joiner does not advertise agent text stream roles this group requires: " + - missing.joinToString { AppComponentIds.toHex(it) } - } - } - - /** - * Leaf capabilities for the current profile. - * - * RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only - * the draft `app_data_dictionary` extension and the `app_data_update` - * proposal appear — `required_capabilities` support is implicit. - * - * The legacy `0xF2EE` group-data extension is advertised alongside - * them, and that is not a hedge. A capability says "this client can - * handle it", not "this group uses it", and a group that REQUIRES - * `0xF2EE` refuses to add a leaf that does not advertise it. Without - * this line a current-profile KeyPackage would be un-addable to every - * legacy group that already exists — the exact mirror of the interop - * failure the current profile was adopted to fix. - * - * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: - * a group carrying component `0x8006` with `required_member_roles` - * naming `receive` refuses a leaf that does not advertise it. The - * reference client puts exactly that policy into EVERY group it - * creates, so without this line an Amethyst KeyPackage cannot be - * invited into one at all. - * - * We stop at receive. `send` and `fanout` are not here because we do - * not originate previews from the app, and a capability is a standing - * promise rather than a hedge. - */ - fun currentProfileLeafCapabilities(): Capabilities = - Capabilities( - extensions = - listOf( - AppDataDictionary.EXTENSION_TYPE, - MarmotGroupData.EXTENSION_ID_INT, - AgentTextStreamRoles.RECEIVE_CAPABILITY, - ), - proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE), - ) - - /** - * `required_capabilities` for a new current-profile group: extension - * `0x0006` and proposal `0x0008`. - * - * The Marmot components a group requires are negotiated in the - * upstream `app_components` component INSIDE the dictionary, not here — - * MLS `RequiredCapabilities` carries only MLS-level primitives. - */ - fun buildCurrentProfileRequiredCapabilitiesExtension(): Extension { - val writer = TlsWriter() - val exts = TlsWriter() - exts.putUint16(AppDataDictionary.EXTENSION_TYPE) - writer.putOpaqueVarInt(exts.toByteArray()) - val props = TlsWriter() - props.putUint16(APP_DATA_UPDATE_PROPOSAL_TYPE) - writer.putOpaqueVarInt(props.toByteArray()) - val creds = TlsWriter() - creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) - writer.putOpaqueVarInt(creds.toByteArray()) - return Extension(REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) - } - /** * Create a new MLS group with a single member (the creator). */ @@ -3542,13 +3217,19 @@ class MlsGroup private constructor( * added later by a proposal. */ leafExtensions: List = emptyList(), - capabilities: Capabilities = marmotLeafCapabilities(), /** - * The `required_capabilities` extension for epoch 0. Defaults to - * the MIP-era set; a current-profile group passes - * [buildCurrentProfileRequiredCapabilitiesExtension]. + * The application's rules for this group. Also supplies the + * defaults below, so one argument selects a whole profile. */ - requiredCapabilities: Extension = buildMarmotRequiredCapabilitiesExtension(), + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + capabilities: Capabilities = policy.defaultLeafCapabilities, + /** + * The `required_capabilities` extension for epoch 0. Null means + * the group carries none, which is the RFC 9420 default; a Marmot + * current-profile group passes + * [com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities.currentProfileRequired]. + */ + requiredCapabilities: Extension? = policy.defaultRequiredCapabilities, ): MlsGroup { val sigKp = signingKey?.let { key -> @@ -3574,11 +3255,11 @@ class MlsGroup private constructor( tree.setLeaf(0, leafNode) val treeHash = tree.treeHash() - // Start with required_capabilities + whatever the caller wants to - // bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so - // new peers who join later can see the group name without first - // decrypting a pre-membership bootstrap commit — see MIP-03). - val baseExtensions = listOf(requiredCapabilities) + // Start with required_capabilities, when the profile has any, plus + // whatever the caller wants baked into epoch 0 — typically the + // binding's own group-metadata extension, so a later joiner can read + // it without first decrypting a pre-membership bootstrap commit. + val baseExtensions = listOfNotNull(requiredCapabilities) val groupContext = GroupContext( groupId = groupId, @@ -3607,6 +3288,7 @@ class MlsGroup private constructor( signingPrivateKey = sigKp.privateKey, encryptionPrivateKey = encKp.privateKey, interimTranscriptHash = ByteArray(0), + policy = policy, ) } @@ -3619,6 +3301,7 @@ class MlsGroup private constructor( fun processWelcome( welcomeBytes: ByteArray, bundle: KeyPackageBundle, + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, ): MlsGroup { val mlsMsg = MlsMessage.decodeTls(TlsReader(welcomeBytes)) require(mlsMsg.wireFormat == WireFormat.WELCOME) { "Expected Welcome message" } @@ -3759,7 +3442,15 @@ class MlsGroup private constructor( // must advertise. MLS cannot enforce it, so a joiner that skipped // this check would join a group it can never satisfy and have // every one of its commits refused by peers that do check. - requireAgentTextStreamRoles(groupContext.extensions, tree, myLeafIndex) + policy.validateJoin( + GroupView( + extensions = groupContext.extensions, + leafCount = tree.leafCount, + myLeafIndex = myLeafIndex, + identityAt = { (tree.getLeaf(it)?.credential as? Credential.Basic)?.identity?.toHexKey() }, + capabilitiesAt = { tree.getLeaf(it)?.capabilities }, + ), + ) // Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3) // For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh) @@ -3835,6 +3526,7 @@ class MlsGroup private constructor( signingPrivateKey = bundle.signaturePrivateKey, encryptionPrivateKey = bundle.encryptionPrivateKey, interimTranscriptHash = interimTranscriptHash, + policy = policy, ) groupSecrets.pathSecret?.let { pathSecret -> val ancestorIdx = joined.directPathIndexOfAncestorWith(groupInfo.signer) @@ -3868,7 +3560,8 @@ class MlsGroup private constructor( groupInfoBytes: ByteArray, identity: ByteArray, signingKey: ByteArray? = null, - capabilities: Capabilities = marmotLeafCapabilities(), + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + capabilities: Capabilities = policy.defaultLeafCapabilities, leafExtensions: List = emptyList(), ): ExternalJoinResult { val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes)) @@ -4098,6 +3791,7 @@ class MlsGroup private constructor( signingPrivateKey = sigKp.privateKey, encryptionPrivateKey = encKp.privateKey, interimTranscriptHash = interimTranscriptHash, + policy = policy, ) // Wrap the commit in a PublicMessage envelope so existing members @@ -4144,7 +3838,10 @@ class MlsGroup private constructor( * or senders we never decrypted) simply re-derive from generation 0 on * first use — safe, because those messages were already processed. */ - fun restore(state: MlsGroupState): MlsGroup { + fun restore( + state: MlsGroupState, + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + ): MlsGroup { val tree = RatchetTree.decodeTls(TlsReader(state.treeBytes)) val secretTree = SecretTree(state.encryptionSecret, tree.leafCount) secretTree.importSenderStates(state.senderRatchetStates) @@ -4161,6 +3858,7 @@ class MlsGroup private constructor( interimTranscriptHash = state.interimTranscriptHash, pathPrivateKeys = state.pathPrivateKeys.toMutableMap(), pendingProposals = state.pendingProposals.toMutableList(), + policy = policy, ) } @@ -4176,7 +3874,7 @@ class MlsGroup private constructor( groupId: ByteArray? = null, leafIndex: Int? = null, parentHash: ByteArray? = null, - capabilities: Capabilities = marmotLeafCapabilities(), + capabilities: Capabilities, leafExtensions: List = emptyList(), ): LeafNode { val unsigned = @@ -4268,12 +3966,10 @@ class MlsGroup private constructor( * return value is the epoch this message must be outer-encrypted under. */ fun buildSelfRemoveProposalMessage(): Pair { - check(!isLocalAdmin()) { - "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" - } + policy.authorizeSelfRemove(view()) val preCommitExporterSecret = - exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + policy.commitExporter?.let { exporterSecret(it.label, it.context, it.length) } ?: ByteArray(0) val proposal = Proposal.SelfRemove() val proposalBytes = proposal.toTlsBytes() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt new file mode 100644 index 0000000000..175ac62297 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * The application's rules about who in a group may do what. + * + * RFC 9420 says who may *send* a proposal and never who may *commit* one: + * beyond the protocol's own validity checks, any member may commit anything. + * Real deployments need more than that — Marmot's MIP-03 names a set of admin + * accounts and allows everyone else only a self-Update or a SelfRemove — but + * that is the application's rule, not the protocol's, and an engine with one + * binding's rules compiled into it cannot host a second binding. + * + * Every hook defaults to permissive, and that direction is deliberate. A + * policy defaulting to closed would make the engine unusable without one and + * would tempt callers into a "policy that allows everything" anyway; defaulting + * to open puts each restriction in the binding that actually documents it. + * + * The cost of that choice is that a group built or restored without its policy + * silently drops the binding's rules. A policy is behaviour, not state, so it + * is **not** carried in [MlsGroupState] — whatever restores a group has to + * supply the same policy it was created with. For Marmot that is + * `MlsGroupManager`, which is the only thing that restores a group in order to + * commit with it. + */ +interface MlsGroupPolicy { + /** + * Rejects, by throwing, a commit the application does not allow. + * + * Called for both directions — before building a local commit and before + * applying an inbound one — with [committerLeafIndex] identifying whose + * commit it is. Returning normally means "allowed"; the engine's own RFC + * 9420 validation runs regardless and is not something a policy can waive. + */ + fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) = Unit + + /** + * Rejects, by throwing, a SelfRemove the local member is not allowed to + * issue. + * + * Separate from [authorizeCommit] because it gates a *proposal* at its + * sender rather than a commit: Marmot requires an admin to first + * self-demote through a GroupContextExtensions proposal, and catching that + * locally is the difference between a clear error here and a commit every + * peer silently refuses. + */ + fun authorizeSelfRemove(group: GroupView) = Unit + + /** + * Rejects, by throwing, a group this client should not finish joining. + * + * Runs once the Welcome has been processed far enough to see the group's + * extensions and tree, so a policy can enforce requirements MLS itself + * cannot carry — a component that demands leaf capabilities outside + * `required_capabilities`, for instance. Failing here beats joining a group + * whose every commit peers would reject. + */ + fun validateJoin(group: GroupView) = Unit + + /** + * Leaf capabilities a new leaf advertises when the caller names none. + * + * Empty by default: RFC 9420 §7.2 forbids advertising the DEFAULT + * extension and proposal types, so a group that requires nothing beyond + * them needs nothing here. + */ + val defaultLeafCapabilities: Capabilities get() = Capabilities() + + /** + * The epoch-0 `required_capabilities` extension when the caller names none. + * + * Null means the group carries no such extension at all, which is the RFC + * 9420 default — requirements only ever restrict which leaves may join, so + * a binding that needs one says so. + */ + val defaultRequiredCapabilities: Extension? get() = null + + /** + * Extension types this application understands, beyond the RFC 9420 set. + * + * A GroupContextExtensions proposal naming a type outside the union of + * this and the engine's own set is rejected: accepting an extension we + * cannot evaluate would mean committing to a requirement we cannot check. + */ + val knownExtensionTypes: Set get() = emptySet() + + /** + * How this binding derives the pre-commit exporter secret a [CommitResult] + * carries, or null if it seals nothing outside MLS. + * + * A binding that wraps MLS messages in its own encryption needs a key both + * the committer and the members still at epoch N can derive, and RFC 9420 + * gives it one through `MLS-Exporter` — but the label is the application's, + * and `"marmot"` was hardcoded here. Null yields the empty secret that + * [CommitResult] already defaults to. + */ + val commitExporter: MlsExporterLabel? get() = null + + companion object { + /** RFC 9420 exactly as written: any member may commit anything valid. */ + val Permissive: MlsGroupPolicy = object : MlsGroupPolicy {} + } +} + +/** + * The read-only slice of a group that a policy decision may look at. + * + * A projection rather than the [MlsGroup] itself. A policy handed the group + * could commit, rotate keys, or mutate epoch state from inside the very check + * meant to gate those things; passing only what a decision needs makes that + * impossible to write by accident. The accessors are functions rather than + * materialised collections so that a policy which inspects one leaf does not + * pay for walking the whole tree. + */ +class GroupView( + /** + * GroupContext extensions as they stand *before* the commit under + * consideration applies. A commit that replaces them carries the + * replacement in its own GroupContextExtensions proposal, which the policy + * reads from the proposal list. + */ + val extensions: List, + /** Leaf count of the ratchet tree, counting blank leaves. */ + val leafCount: Int, + /** The local member's leaf index. */ + val myLeafIndex: Int, + private val identityAt: (Int) -> String?, + private val capabilitiesAt: (Int) -> Capabilities?, +) { + /** Lowercase hex of the BasicCredential identity at [leafIndex], or null if blank. */ + fun memberIdentityHex(leafIndex: Int): String? = identityAt(leafIndex) + + /** Capabilities advertised by the leaf at [leafIndex], or null if blank. */ + fun leafCapabilities(leafIndex: Int): Capabilities? = capabilitiesAt(leafIndex) +} + +/** + * The three inputs to `MLS-Exporter` (RFC 9420 §8.5) that identify one + * application's key derivation. + * + * Not a data class: [context] is a ByteArray, whose `equals` is identity, so + * generated equality would quietly be wrong. + */ +class MlsExporterLabel( + val label: String, + val context: ByteArray, + val length: Int, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt index 638bb2a5b4..ee76dd9751 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.codec.TlsSerializable import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.mls.tree.LeafNode @@ -93,7 +93,7 @@ data class MlsKeyPackage( */ fun isLastResort(): Boolean = extensions.any { it.extensionType == LAST_RESORT_EXTENSION_TYPE } || - AppDataDictionary.fromExtensionsOrEmpty(extensions).contains(AppComponentIds.LAST_RESORT_KEY_PACKAGE) + AppDataDictionary.fromExtensionsOrEmpty(extensions).contains(ComponentsList.LAST_RESORT_KEY_PACKAGE_ID) /** * Encode the TBS (to-be-signed) portion for signature verification. diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt index 4eaaa5c29b..711852c67e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent @@ -57,7 +59,7 @@ class MarmotMipBehaviorTest { private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore()) private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle { - val tempGroup = MlsGroup.create(identity.hexToByteArray()) + val tempGroup = MlsGroup.create(identity.hexToByteArray(), policy = MarmotGroupPolicy) return tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0)) } @@ -67,7 +69,7 @@ class MarmotMipBehaviorTest { @Test fun create_installsRequiredCapabilitiesExtension() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) // RFC 9420 §13.3: required_capabilities is extension type 0x0003. val reqCaps = alice.extensions.find { it.extensionType == 0x0003 } @@ -416,7 +418,7 @@ class MarmotMipBehaviorTest { ) val ex = assertFailsWith { - alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 1) + MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 1) } assertTrue( ex.message!!.contains("non-admin members may only commit"), @@ -454,7 +456,7 @@ class MarmotMipBehaviorTest { ), ) // Should not throw. - alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 0) + MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 0) } @Test @@ -488,7 +490,7 @@ class MarmotMipBehaviorTest { ), ) assertFailsWith { - alice.enforceNoAdminDepletion(proposals) + MarmotGroupPolicy.enforceNoAdminDepletion(alice.view(), proposals) } } @@ -639,7 +641,7 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val welcomeBytes = requireNotNull(commitResult.welcomeBytes) { "addMember must produce a Welcome" } - val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle) + val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle, policy = MarmotGroupPolicy) return alice to bob } @@ -715,7 +717,7 @@ class MarmotMipBehaviorTest { */ @Test fun verifyTreeParentHashesForJoin_acceptsSingleMemberTree() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val tree = com.vitorpamplona.quartz.mls.tree.RatchetTree .decodeTls( @@ -798,7 +800,7 @@ class MarmotMipBehaviorTest { */ @Test fun findRequiredCapabilities_decodesMarmotExtensionInstalledByCreate() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val req = MlsGroup.findRequiredCapabilities(alice.extensions) ?: error("required_capabilities must be present after create()") @@ -906,7 +908,7 @@ class MarmotMipBehaviorTest { * validates. Useful for testing the §7.2 gate in isolation. */ private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.mls.messages.MlsKeyPackage { - val tempGroup = MlsGroup.create(identity.hexToByteArray()) + val tempGroup = MlsGroup.create(identity.hexToByteArray(), policy = MarmotGroupPolicy) val bundle = tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0)) val original = bundle.keyPackage val originalLeaf = original.leafNode @@ -947,7 +949,7 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_emptyListReturnsAllZeros() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val out = alice.computePskSecret(emptyList()) assertEquals(32, out.size, "psk_secret length must be Nh = 32 for SHA-256") assertTrue(out.all { it == 0.toByte() }, "default_psk_secret is all zeros") @@ -970,7 +972,7 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_singleExternalPsk_matchesSpecDerivation() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val pskId = ByteArray(16) { (it + 1).toByte() } val pskNonce = ByteArray(16) { (0x80 or it).toByte() } val pskValue = ByteArray(32) { (0xA0 or (it and 0x0F)).toByte() } @@ -1017,7 +1019,7 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_resumptionPskRejectsUntilProposalWidened() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val pskId = ByteArray(16) { it.toByte() } alice.registerPsk(pskId, ByteArray(32)) @@ -1038,7 +1040,7 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_orderingChangesOutput() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val idA = ByteArray(16) { 0x11 } val idB = ByteArray(16) { 0x22 } alice.registerPsk(idA, ByteArray(32) { 0x33 }) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt index f1d45dab74..20fe82cef6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.quartz.marmot.appComponents +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.mls.components.AppDataDictionary import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -47,7 +52,7 @@ class CurrentProfileAuthorizationTest { creator: ByteArray, admins: List, ): MlsGroup { - val group = MlsGroup.create(creator) + val group = MlsGroup.create(creator, policy = MarmotGroupPolicy) val dictionary = MarmotGroupState.buildDictionary( adminPolicy = AdminPolicyV1.of(admins), @@ -79,7 +84,7 @@ class CurrentProfileAuthorizationTest { val alice = currentProfileGroup(aliceAccount, listOf(aliceAccount)) val bobBundle = alice.createKeyPackage(bobAccount, ByteArray(0)) val add = alice.addMember(bobBundle.keyPackage.toTlsBytes()) - val bob = MlsGroup.processWelcome(add.welcomeBytes!!, bobBundle) + val bob = MlsGroup.processWelcome(add.welcomeBytes!!, bobBundle, policy = MarmotGroupPolicy) assertTrue(!bob.isLocalAdmin()) assertEquals(setOf(aliceAccount.toHexKey()), bob.currentAdminIdentities()) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt index c79cd05d86..6746a556db 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.mls.codec.TlsReader diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt new file mode 100644 index 0000000000..289d2e8224 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 +import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState +import com.vitorpamplona.quartz.marmot.appComponents.NostrRoutingV1 +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * That MIP-03 enforcement now travels with [MarmotGroupPolicy] rather than + * with the engine. + * + * Every other authorization test asserts that Marmot refuses. This one asserts + * the other half, which is what the extraction actually changed: the same + * group, at the same state, accepts the same commit once the policy is gone. + * If someone re-hardcodes the rules into `MlsGroup` these tests fail, and a + * cordn or plain RFC 9420 group would be back to inheriting Marmot's rules + * without asking. + */ +class MarmotPolicySeamTest { + private val alice = "11".repeat(32).hexToByteArray() + private val bob = "22".repeat(32).hexToByteArray() + + /** A group created by [creator] whose admin policy names only [admins]. */ + private fun groupAdminedBy( + creator: ByteArray, + admins: List, + ): MlsGroup { + val group = MlsGroup.create(creator, policy = MarmotGroupPolicy) + val dictionary = + MarmotGroupState.buildDictionary( + adminPolicy = AdminPolicyV1.of(admins), + routing = NostrRoutingV1.of(ByteArray(32) { 0x5a }, listOf("wss://relay.example")), + profile = GroupProfileV1("Seam", ""), + ) + // Bootstrap: installed before any admin is named, which MIP-01 allows. + group.proposeGroupContextExtensions(listOf(dictionary.toExtension())) + group.commit() + return group + } + + @Test + fun marmotsPolicyRefusesANonAdminExtensionChange() { + val group = groupAdminedBy(creator = alice, admins = listOf(bob)) + assertTrue(!group.isLocalAdmin(), "alice must not be an admin for this to test anything") + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith("a non-admin must not be able to rewrite group state") { + group.commit() + } + } + + @Test + fun theSameCommitIsAcceptedOnceTheGroupCarriesNoPolicy() { + val marmot = groupAdminedBy(creator = alice, admins = listOf(bob)) + val epochBefore = marmot.epoch + + // Same state, same proposal, no binding rules. + val plain = MlsGroup.restore(marmot.saveState(), MlsGroupPolicy.Permissive) + plain.proposeGroupContextExtensions(plain.extensions) + plain.commit() + + assertEquals( + epochBefore + 1, + plain.epoch, + "RFC 9420 places no limit on who may commit; only the binding does", + ) + } + + @Test + fun marmotsProfileTravelsWithItsPolicy() { + val plain = MlsGroup.create(alice) + val marmot = MlsGroup.create(alice, policy = MarmotGroupPolicy) + + assertTrue( + plain.extensions.none { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "the engine's own default must require nothing", + ) + assertTrue( + marmot.extensions.any { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "naming MarmotGroupPolicy must still install required_capabilities", + ) + assertEquals( + listOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE), + MarmotGroupPolicy.defaultLeafCapabilities.extensions, + "and the MIP-era leaf set, which used to be MlsGroup.create's hardcoded default", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt index 6eb6eb87d3..7226a5fbb6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.groups +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData @@ -147,7 +148,7 @@ class MlsGroupManagerTest { // group is enough to observe the ratchet behavior.) val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val addResult = alice.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) - val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle, policy = MarmotGroupPolicy) // Alice sends generation 0 (no commit); Bob consumes it. val ct0 = alice.encrypt(groupId, "msg0".encodeToByteArray()) @@ -314,7 +315,7 @@ class MlsGroupManagerTest { // production before a Welcome has ever been seen). val bobBundle1 = MlsGroup - .create("bob".encodeToByteArray()) + .create("bob".encodeToByteArray(), policy = MarmotGroupPolicy) .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes()) val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome") @@ -371,7 +372,7 @@ class MlsGroupManagerTest { // --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. - val bobBundle2 = MlsGroup - .create("bob".encodeToByteArray()) + .create("bob".encodeToByteArray(), policy = MarmotGroupPolicy) .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes()) val secondWelcome = diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt index 0f31400746..3ee36933a8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt @@ -24,6 +24,12 @@ import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData +import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId import com.vitorpamplona.quartz.mls.crypto.Ed25519 import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle @@ -87,7 +93,7 @@ class CurrentProfileWelcomeTest { val commit = group.commit() val welcome = assertNotNull(commit.welcomeBytes, "adding a member must produce a Welcome") - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) assertEquals(group.currentGroupState().profile?.name, joined.currentGroupState().profile?.name) } @@ -122,7 +128,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) } assertTrue( failure.message.orEmpty().contains("agent text stream roles"), "expected a role-capability refusal, got: ${failure.message}", @@ -151,7 +157,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } @@ -180,7 +186,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } @@ -215,7 +221,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) } assertTrue( failure.message.orEmpty().contains("agent text stream roles"), "expected a role-capability refusal, got: ${failure.message}", @@ -253,7 +259,7 @@ class CurrentProfileWelcomeTest { ): KeyPackageBundle { val full = CurrentProfileGroupFactory.createKeyPackage(signer) val reduced = - MlsGroup.currentProfileLeafCapabilities().let { + MarmotCapabilities.currentProfileLeaf().let { Capabilities( extensions = it.extensions + roles, proposals = it.proposals, @@ -289,7 +295,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals( AgentTextStreamQuicPolicyV1.userToAgentDefault(), joined.currentGroupState().agentTextStream, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt new file mode 100644 index 0000000000..34e9ac85d4 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.tree.Capabilities +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The [MlsGroupPolicy] seam itself, with no binding involved. + * + * The engine used to carry Marmot's authorization rules and capability + * defaults inline, so a plain RFC 9420 group could not be created at all — + * every group came out requiring `marmot_group_data`. These tests pin the two + * halves of the fix: the default really is RFC 9420 as written, and a policy + * that refuses really is consulted rather than advisory. + */ +class MlsGroupPolicySeamTest { + private val alice = "alice".encodeToByteArray() + + /** Records what the engine asked, and refuses on demand. */ + private class RecordingPolicy( + val refuseCommit: Boolean = false, + val refuseSelfRemove: Boolean = false, + override val commitExporter: MlsExporterLabel? = null, + ) : MlsGroupPolicy { + var commitsSeen = 0 + var selfRemovesSeen = 0 + var lastCommitter: Int? = null + + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + commitsSeen++ + lastCommitter = committerLeafIndex + if (refuseCommit) throw IllegalStateException("refused by policy") + } + + override fun authorizeSelfRemove(group: GroupView) { + selfRemovesSeen++ + if (refuseSelfRemove) throw IllegalStateException("no leaving") + } + } + + @Test + fun aDefaultGroupRequiresNothingBeyondRfc9420() { + val group = MlsGroup.create(alice) + + assertFalse( + group.extensions.any { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "the default profile must not install required_capabilities — that was Marmot's, not RFC 9420's", + ) + assertEquals( + Capabilities(), + MlsGroupPolicy.Permissive.defaultLeafCapabilities, + "RFC 9420 §7.2 forbids advertising DEFAULT types, so the neutral leaf advertises nothing", + ) + } + + @Test + fun authorizeCommitIsConsultedWithTheLocalCommitter() { + val policy = RecordingPolicy() + val group = MlsGroup.create(alice, policy = policy) + + group.proposeGroupContextExtensions(group.extensions) + group.commit() + + assertEquals(1, policy.commitsSeen) + assertEquals(group.leafIndex, policy.lastCommitter) + } + + @Test + fun aRefusedCommitDoesNotAdvanceTheEpoch() { + val policy = RecordingPolicy(refuseCommit = true) + val group = MlsGroup.create(alice, policy = policy) + val epochBefore = group.epoch + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith { group.commit() } + + assertEquals( + epochBefore, + group.epoch, + "a policy refusal must abort before any mutation, or the group diverges from every peer", + ) + } + + @Test + fun authorizeSelfRemoveGatesTheProposalAtItsSender() { + val allowed = RecordingPolicy() + MlsGroup.create(alice, policy = allowed).proposeSelfRemove() + assertEquals(1, allowed.selfRemovesSeen) + + val refused = RecordingPolicy(refuseSelfRemove = true) + val group = MlsGroup.create(alice, policy = refused) + assertFailsWith { group.proposeSelfRemove() } + assertTrue(group.pendingProposalsSnapshot().isEmpty(), "a refused proposal must not be staged") + } + + @Test + fun theCommitExporterSecretComesFromThePolicy() { + val none = MlsGroup.create(alice, policy = RecordingPolicy()) + none.proposeGroupContextExtensions(none.extensions) + assertEquals( + 0, + none.commit().preCommitExporterSecret.size, + "a binding that seals nothing outside MLS gets no secret", + ) + + val label = MlsExporterLabel("myapp", "group-event".encodeToByteArray(), 32) + val bound = MlsGroup.create(alice, policy = RecordingPolicy(commitExporter = label)) + val expected = bound.exporterSecret(label.label, label.context, label.length) + bound.proposeGroupContextExtensions(bound.extensions) + + assertContentEquals( + expected, + bound.commit().preCommitExporterSecret, + "the engine must derive it under the policy's label, not one of its own", + ) + } +}