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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-18 16:13:45 +00:00
parent 5063e0d12e
commit 759a39c9af
21 changed files with 1126 additions and 456 deletions
@@ -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.
@@ -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
@@ -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 ----
@@ -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(),
)
}
}
@@ -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<Int>,
proposals: List<Int>,
): Extension {
val writer = TlsWriter()
// extensions<V>: uint16 each
val exts = TlsWriter()
extensions.forEach { exts.putUint16(it) }
writer.putOpaqueVarInt(exts.toByteArray())
// proposals<V>: uint16 each
val props = TlsWriter()
proposals.forEach { props.putUint16(it) }
writer.putOpaqueVarInt(props.toByteArray())
// credentials<V>: uint16 each
val creds = TlsWriter()
creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC)
writer.putOpaqueVarInt(creds.toByteArray())
return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray())
}
}
@@ -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<Int> 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<PendingProposal>,
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<Extension>): Set<String> {
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<PendingProposal>,
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<PendingProposal>,
) {
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<Proposal.GroupContextExtensions>()
.lastOrNull()
val extensionsAfterGce = gce?.extensions ?: group.extensions
val adminUpdate =
proposals
.asSequence()
.map { it.proposal }
.filterIsInstance<Proposal.AppDataUpdate>()
.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<Int>()
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<String>()
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) }
}
}
}
@@ -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<String> = 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,
)
@@ -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<MlsGroup, HexKey> =
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())
}
@@ -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,
)
}
@@ -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
@@ -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<Int> = dictionary[APP_COMPONENTS_ID]?.let { decode(it) } ?: emptyList()
}
@@ -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<Int, ByteArray> = 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<String> = 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<Extension>): Set<String> {
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<String> = (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<Extension> = emptyList(),
capabilities: Capabilities = marmotLeafCapabilities(),
capabilities: Capabilities = policy.defaultLeafCapabilities,
keyPackageExtensions: List<Extension> = 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<PendingProposal>()
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<PendingProposal>,
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<PendingProposal>) {
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<Proposal.GroupContextExtensions>()
.lastOrNull()
val extensionsAfterGce = gce?.extensions ?: groupContext.extensions
val adminUpdate =
proposals
.asSequence()
.map { it.proposal }
.filterIsInstance<Proposal.AppDataUpdate>()
.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<Int>()
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<String>()
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<V>: uint16 each
val exts = TlsWriter()
exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE)
writer.putOpaqueVarInt(exts.toByteArray())
// proposals<V>: uint16 each
val props = TlsWriter()
props.putUint16(SELF_REMOVE_PROPOSAL_TYPE)
writer.putOpaqueVarInt(props.toByteArray())
// credentials<V>: 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<Extension>,
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<Extension> = 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<Extension> = 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<Extension> = 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<ByteArray, ByteArray> {
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()
@@ -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<PendingProposal>,
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<Int> 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<Extension>,
/** 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,
)
@@ -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.
@@ -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<IllegalStateException> {
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<IllegalStateException> {
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 })
@@ -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<ByteArray>,
): 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())
@@ -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
@@ -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<ByteArray>,
): 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<IllegalStateException>("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",
)
}
}
@@ -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 =
@@ -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<IllegalArgumentException> { MlsGroup.processWelcome(welcome, invitee) }
val failure = assertFailsWith<IllegalArgumentException> { 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<IllegalArgumentException> { MlsGroup.processWelcome(welcome, invitee) }
val failure = assertFailsWith<IllegalArgumentException> { 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,
@@ -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<PendingProposal>,
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<IllegalStateException> { 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<IllegalStateException> { 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",
)
}
}