mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+11
-8
@@ -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.MarmotMessageEdit
|
||||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
|
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
|
||||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff
|
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.KeyPackageBundleStore
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
|
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.mip02Welcome.WelcomeEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
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.GroupLifecycleState
|
||||||
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
|
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
|
||||||
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishGate
|
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishGate
|
||||||
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligation
|
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligation
|
||||||
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
|
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
|
||||||
import com.vitorpamplona.quartz.marmot.protocolCore.PublishOutcome
|
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.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
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.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central coordinator for Marmot MLS group messaging.
|
* Central coordinator for Marmot MLS group messaging.
|
||||||
|
|||||||
+1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||||
import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager
|
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.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||||
|
|||||||
+8
-3
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot.appComponents
|
package com.vitorpamplona.quartz.marmot.appComponents
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.mls.components.ComponentsList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marmot app-component ids, from the spec's `foundation/registries.md`.
|
* Marmot app-component ids, from the spec's `foundation/registries.md`.
|
||||||
*
|
*
|
||||||
@@ -39,11 +41,14 @@ package com.vitorpamplona.quartz.marmot.appComponents
|
|||||||
object AppComponentIds {
|
object AppComponentIds {
|
||||||
// ---- upstream, draft-ietf-mls-extensions-10 ----
|
// ---- 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. */
|
/** `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`. */
|
/** `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
|
* `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
|
* MIP-era profile marked last resort with extension `0x000a`, which is now
|
||||||
* the `self_remove` PROPOSAL type.
|
* 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 ----
|
// ---- Marmot private range ----
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.marmot.appComponents
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2
|
import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1
|
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.marmot.mip01Groups.MlsCiphersuite
|
||||||
import com.vitorpamplona.quartz.mls.components.AppDataDictionary
|
import com.vitorpamplona.quartz.mls.components.AppDataDictionary
|
||||||
import com.vitorpamplona.quartz.mls.components.ComponentData
|
import com.vitorpamplona.quartz.mls.components.ComponentData
|
||||||
@@ -161,7 +163,7 @@ object CurrentProfileGroupFactory {
|
|||||||
signingKey = leaf.signatureKeyPair.privateKey,
|
signingKey = leaf.signatureKeyPair.privateKey,
|
||||||
leafSignatureKeyPair = leaf.signatureKeyPair,
|
leafSignatureKeyPair = leaf.signatureKeyPair,
|
||||||
leafExtensions = leaf.leafExtensions,
|
leafExtensions = leaf.leafExtensions,
|
||||||
capabilities = MlsGroup.currentProfileLeafCapabilities(),
|
capabilities = MarmotCapabilities.currentProfileLeaf(),
|
||||||
keyPackageExtensions = keyPackageExtensions,
|
keyPackageExtensions = keyPackageExtensions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -218,8 +220,11 @@ object CurrentProfileGroupFactory {
|
|||||||
signingKey = leaf.signatureKeyPair.privateKey,
|
signingKey = leaf.signatureKeyPair.privateKey,
|
||||||
initialExtensions = listOf(dictionary.toExtension()),
|
initialExtensions = listOf(dictionary.toExtension()),
|
||||||
leafExtensions = leaf.leafExtensions,
|
leafExtensions = leaf.leafExtensions,
|
||||||
capabilities = MlsGroup.currentProfileLeafCapabilities(),
|
// The current profile shares Marmot's authorization rules but
|
||||||
requiredCapabilities = MlsGroup.buildCurrentProfileRequiredCapabilitiesExtension(),
|
// advertises a different capability set, so both are named here.
|
||||||
|
policy = MarmotGroupPolicy,
|
||||||
|
capabilities = MarmotCapabilities.currentProfileLeaf(),
|
||||||
|
requiredCapabilities = MarmotCapabilities.currentProfileRequired(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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,
|
||||||
|
)
|
||||||
+9
-6
@@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.marmot.groups
|
|||||||
import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1
|
import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1
|
import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1
|
||||||
import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW
|
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.TlsReader
|
||||||
import com.vitorpamplona.quartz.mls.codec.TlsWriter
|
import com.vitorpamplona.quartz.mls.codec.TlsWriter
|
||||||
import com.vitorpamplona.quartz.mls.components.ComponentsList
|
import com.vitorpamplona.quartz.mls.components.ComponentsList
|
||||||
@@ -141,7 +144,7 @@ class MlsGroupManager(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val state = MlsGroupState.decodeTls(stateBytes)
|
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)" }
|
Log.d(TAG) { "restoreAll(): restored group $nostrGroupId (${stateBytes.size} bytes)" }
|
||||||
|
|
||||||
// Restore retained epochs
|
// Restore retained epochs
|
||||||
@@ -214,7 +217,7 @@ class MlsGroupManager(
|
|||||||
if (!changed) return@withLock
|
if (!changed) return@withLock
|
||||||
|
|
||||||
val outgoing = current?.retainedSecrets()
|
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
|
// 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
|
// same-epoch rewind swaps one epoch-N state for a different one, and
|
||||||
// the abandoned N still has traffic addressed to it.
|
// the abandoned N still has traffic addressed to it.
|
||||||
@@ -278,7 +281,7 @@ class MlsGroupManager(
|
|||||||
): MlsGroup =
|
): MlsGroup =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
Log.d(TAG) { "createGroup($nostrGroupId): creating new MLS group" }
|
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
|
groups[nostrGroupId] = group
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" }
|
Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" }
|
||||||
@@ -309,7 +312,7 @@ class MlsGroupManager(
|
|||||||
hintNostrGroupId: HexKey? = null,
|
hintNostrGroupId: HexKey? = null,
|
||||||
): Pair<MlsGroup, HexKey> =
|
): Pair<MlsGroup, HexKey> =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val group = MlsGroup.processWelcome(welcomeBytes, bundle)
|
val group = MlsGroup.processWelcome(welcomeBytes, bundle, MarmotGroupPolicy)
|
||||||
|
|
||||||
val derivedId =
|
val derivedId =
|
||||||
group.currentNostrGroupId()
|
group.currentNostrGroupId()
|
||||||
@@ -349,7 +352,7 @@ class MlsGroupManager(
|
|||||||
signingKey: ByteArray? = null,
|
signingKey: ByteArray? = null,
|
||||||
): ExternalJoinResult =
|
): ExternalJoinResult =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey)
|
val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey, policy = MarmotGroupPolicy)
|
||||||
groups[nostrGroupId] = result.group
|
groups[nostrGroupId] = result.group
|
||||||
persistGroup(nostrGroupId)
|
persistGroup(nostrGroupId)
|
||||||
result
|
result
|
||||||
@@ -413,7 +416,7 @@ class MlsGroupManager(
|
|||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val live = requireGroup(nostrGroupId)
|
val live = requireGroup(nostrGroupId)
|
||||||
val priorState = live.saveState()
|
val priorState = live.saveState()
|
||||||
val clone = MlsGroup.restore(priorState)
|
val clone = MlsGroup.restore(priorState, MarmotGroupPolicy)
|
||||||
val result = prepare(clone)
|
val result = prepare(clone)
|
||||||
StagedCommit(result, priorState, clone.saveState())
|
StagedCommit(result, priorState, clone.saveState())
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -20,7 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot.protocolCore
|
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.MlsGroupManager
|
||||||
|
import com.vitorpamplona.quartz.marmot.groups.currentGroupState
|
||||||
import com.vitorpamplona.quartz.mls.framing.ContentType
|
import com.vitorpamplona.quartz.mls.framing.ContentType
|
||||||
import com.vitorpamplona.quartz.mls.group.MlsGroup
|
import com.vitorpamplona.quartz.mls.group.MlsGroup
|
||||||
import com.vitorpamplona.quartz.mls.group.MlsGroupState
|
import com.vitorpamplona.quartz.mls.group.MlsGroupState
|
||||||
@@ -421,7 +423,7 @@ class MarmotConvergenceEngine(
|
|||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
contexts[groupId]?.candidateStates?.values?.mapNotNull { state ->
|
contexts[groupId]?.candidateStates?.values?.mapNotNull { state ->
|
||||||
try {
|
try {
|
||||||
MlsGroup.restore(state).exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
|
MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -451,7 +453,7 @@ class MarmotConvergenceEngine(
|
|||||||
// A clone per attempt: decrypting advances the secret
|
// A clone per attempt: decrypting advances the secret
|
||||||
// tree, and a candidate state gets tried by every
|
// tree, and a candidate state gets tried by every
|
||||||
// message that failed canonically.
|
// message that failed canonically.
|
||||||
MlsGroup.restore(state).decrypt(mlsBytes)
|
MlsGroup.restore(state, MarmotGroupPolicy).decrypt(mlsBytes)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -460,7 +462,7 @@ class MarmotConvergenceEngine(
|
|||||||
stateId = stateId,
|
stateId = stateId,
|
||||||
epoch = decrypted.epoch,
|
epoch = decrypted.epoch,
|
||||||
senderLeafIndex = decrypted.senderLeafIndex,
|
senderLeafIndex = decrypted.senderLeafIndex,
|
||||||
senderAccount = MlsGroup.restore(state).memberIdentityHex(decrypted.senderLeafIndex),
|
senderAccount = MlsGroup.restore(state, MarmotGroupPolicy).memberIdentityHex(decrypted.senderLeafIndex),
|
||||||
content = decrypted.content,
|
content = decrypted.content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot.protocolCore
|
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.codec.TlsReader
|
||||||
import com.vitorpamplona.quartz.mls.framing.ContentType
|
import com.vitorpamplona.quartz.mls.framing.ContentType
|
||||||
import com.vitorpamplona.quartz.mls.framing.MlsMessage
|
import com.vitorpamplona.quartz.mls.framing.MlsMessage
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ object ComponentsList {
|
|||||||
/** Component id of the upstream `safe_aad` list. */
|
/** Component id of the upstream `safe_aad` list. */
|
||||||
const val SAFE_AAD_ID = 0x0002
|
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. */
|
/** 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()
|
fun supportedOrRequired(dictionary: AppDataDictionary): List<Int> = dictionary[APP_COMPONENTS_ID]?.let { decode(it) } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.mls.group
|
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.TlsReader
|
||||||
import com.vitorpamplona.quartz.mls.codec.TlsWriter
|
import com.vitorpamplona.quartz.mls.codec.TlsWriter
|
||||||
import com.vitorpamplona.quartz.mls.components.AppDataDictionary
|
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.PathSecretAndKey
|
||||||
import com.vitorpamplona.quartz.mls.tree.RatchetTree
|
import com.vitorpamplona.quartz.mls.tree.RatchetTree
|
||||||
import com.vitorpamplona.quartz.mls.tree.UpdatePathNode
|
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.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import com.vitorpamplona.quartz.utils.mac.MacInstance
|
import com.vitorpamplona.quartz.utils.mac.MacInstance
|
||||||
@@ -104,8 +96,8 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance
|
|||||||
* // Decrypt application message
|
* // Decrypt application message
|
||||||
* val decrypted = group.decrypt(encrypted)
|
* val decrypted = group.decrypt(encrypted)
|
||||||
*
|
*
|
||||||
* // Export key for Marmot outer encryption
|
* // Export key for a binding's own outer encryption
|
||||||
* val key = group.exporterSecret("marmot", "group-event", 32)
|
* val key = group.exporterSecret("myapp", "group-event".encodeToByteArray(), 32)
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
private fun constantTimeEquals(
|
private fun constantTimeEquals(
|
||||||
@@ -150,6 +142,12 @@ class MlsGroup private constructor(
|
|||||||
* for us".
|
* for us".
|
||||||
*/
|
*/
|
||||||
private val pathPrivateKeys: MutableMap<Int, ByteArray> = mutableMapOf(),
|
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 groupId: ByteArray get() = groupContext.groupId
|
||||||
val epoch: Long get() = groupContext.epoch
|
val epoch: Long get() = groupContext.epoch
|
||||||
@@ -168,6 +166,22 @@ class MlsGroup private constructor(
|
|||||||
*/
|
*/
|
||||||
fun isLocalMember(): Boolean = myLeafIndex < tree.leafCount && tree.getLeaf(myLeafIndex) != null
|
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
|
* Read-only snapshot of the staged-proposal pool. Exposed at module
|
||||||
* scope so tests can inspect what `proposeAdd` / `proposeRemove` /
|
* scope so tests can inspect what `proposeAdd` / `proposeRemove` /
|
||||||
@@ -207,7 +221,7 @@ class MlsGroup private constructor(
|
|||||||
return w.toByteArray()
|
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. */
|
/** 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
|
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. */
|
/** Lowercase hex of the local member's BasicCredential identity, or null. */
|
||||||
fun myIdentityHex(): String? = memberIdentityHex(myLeafIndex)
|
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.
|
* Account identities holding at least one current member leaf, as hex.
|
||||||
*
|
*
|
||||||
* Admin authority is per ACCOUNT, not per leaf: a multi-device account
|
* A set of ACCOUNTS, not leaves: one account may hold several leaves (one
|
||||||
* shares one admin entry across all of its leaves.
|
* per device), and every binding that asks this question means the account.
|
||||||
*/
|
*/
|
||||||
fun currentMemberIdentities(): Set<String> = (0 until tree.leafCount).mapNotNullTo(mutableSetOf()) { memberIdentityHex(it) }
|
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 ---
|
// --- State Persistence ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,7 +378,7 @@ class MlsGroup private constructor(
|
|||||||
*/
|
*/
|
||||||
leafSignatureKeyPair: Ed25519KeyPair? = null,
|
leafSignatureKeyPair: Ed25519KeyPair? = null,
|
||||||
leafExtensions: List<Extension> = emptyList(),
|
leafExtensions: List<Extension> = emptyList(),
|
||||||
capabilities: Capabilities = marmotLeafCapabilities(),
|
capabilities: Capabilities = policy.defaultLeafCapabilities,
|
||||||
keyPackageExtensions: List<Extension> = emptyList(),
|
keyPackageExtensions: List<Extension> = emptyList(),
|
||||||
): KeyPackageBundle {
|
): KeyPackageBundle {
|
||||||
val initKp = X25519.generateKeyPair()
|
val initKp = X25519.generateKeyPair()
|
||||||
@@ -490,15 +437,14 @@ class MlsGroup private constructor(
|
|||||||
/**
|
/**
|
||||||
* Create a SelfRemove proposal.
|
* Create a SelfRemove proposal.
|
||||||
*
|
*
|
||||||
* Per MIP-01/MIP-03, members listed in `admin_pubkeys` MUST NOT issue a
|
* Gated by [MlsGroupPolicy.authorizeSelfRemove], because a binding may
|
||||||
* SelfRemove — they have to first publish a GroupContextExtensions proposal
|
* restrict who can leave unilaterally — Marmot makes an admin self-demote
|
||||||
* removing themselves from the admin list (self-demotion). This guard
|
* through a GroupContextExtensions proposal first. Catching it here rather
|
||||||
* enforces that rule at the local sender.
|
* than on arrival turns a commit every peer would refuse into a local
|
||||||
|
* error.
|
||||||
*/
|
*/
|
||||||
fun proposeSelfRemove(): Proposal.SelfRemove {
|
fun proposeSelfRemove(): Proposal.SelfRemove {
|
||||||
check(!isLocalAdmin()) {
|
policy.authorizeSelfRemove(view())
|
||||||
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
|
|
||||||
}
|
|
||||||
val proposal = Proposal.SelfRemove()
|
val proposal = Proposal.SelfRemove()
|
||||||
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
|
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
|
||||||
return proposal
|
return proposal
|
||||||
@@ -537,7 +483,7 @@ class MlsGroup private constructor(
|
|||||||
signingKey = newSigKp.privateKey,
|
signingKey = newSigKp.privateKey,
|
||||||
groupId = groupId,
|
groupId = groupId,
|
||||||
leafIndex = myLeafIndex,
|
leafIndex = myLeafIndex,
|
||||||
capabilities = currentLeaf?.capabilities ?: marmotLeafCapabilities(),
|
capabilities = currentLeaf?.capabilities ?: policy.defaultLeafCapabilities,
|
||||||
leafExtensions = currentLeaf?.extensions ?: emptyList(),
|
leafExtensions = currentLeaf?.extensions ?: emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -629,25 +575,16 @@ class MlsGroup private constructor(
|
|||||||
fun commit(): CommitResult {
|
fun commit(): CommitResult {
|
||||||
val proposals = pendingProposals.toList()
|
val proposals = pendingProposals.toList()
|
||||||
|
|
||||||
// --- MIP-03 authorization gate -----------------------------------------
|
// 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.
|
||||||
// Non-admin senders may only issue one of two restricted commit shapes:
|
policy.authorizeCommit(view(), proposals, myLeafIndex)
|
||||||
// (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)
|
|
||||||
|
|
||||||
// Capture the pre-commit exporter secret BEFORE any mutation.
|
// Capture the pre-commit exporter secret BEFORE any mutation.
|
||||||
// Publishers of the outbound kind:445 MUST outer-encrypt with this
|
// Publishers of the outbound kind:445 MUST outer-encrypt with this
|
||||||
// key (epoch N) so that other existing members at epoch N can decrypt
|
// key (epoch N) so that other existing members at epoch N can decrypt
|
||||||
// and process the commit. See CommitResult.preCommitExporterSecret.
|
// and process the commit. See CommitResult.preCommitExporterSecret.
|
||||||
val 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
|
// Snapshot the pre-proposal extensions. GroupContextExtensions proposals
|
||||||
// mutate `groupContext.extensions` the moment they're applied, but
|
// mutate `groupContext.extensions` the moment they're applied, but
|
||||||
@@ -842,7 +779,7 @@ class MlsGroup private constructor(
|
|||||||
groupId = groupId,
|
groupId = groupId,
|
||||||
leafIndex = myLeafIndex,
|
leafIndex = myLeafIndex,
|
||||||
parentHash = leafParentHash,
|
parentHash = leafParentHash,
|
||||||
capabilities = previousLeaf?.capabilities ?: marmotLeafCapabilities(),
|
capabilities = previousLeaf?.capabilities ?: policy.defaultLeafCapabilities,
|
||||||
leafExtensions = previousLeaf?.extensions ?: emptyList(),
|
leafExtensions = previousLeaf?.extensions ?: emptyList(),
|
||||||
)
|
)
|
||||||
encryptionPrivateKey = newEncKp.privateKey
|
encryptionPrivateKey = newEncKp.privateKey
|
||||||
@@ -1694,10 +1631,9 @@ class MlsGroup private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve proposal references against our pending pool BEFORE
|
// Resolve proposal references against our pending pool BEFORE
|
||||||
// applying anything, so MIP-03 authorization can run on a static
|
// applying anything, so the policy sees a static snapshot of
|
||||||
// snapshot of (proposal, original-sender-leaf) pairs and so the
|
// (proposal, original-sender-leaf) pairs and can simulate the
|
||||||
// depletion guard can simulate the post-commit tree shape from the
|
// post-commit shape from pre-commit state.
|
||||||
// pre-commit state.
|
|
||||||
val resolvedPending = mutableListOf<PendingProposal>()
|
val resolvedPending = mutableListOf<PendingProposal>()
|
||||||
for (proposalOrRef in commit.proposals) {
|
for (proposalOrRef in commit.proposals) {
|
||||||
when (proposalOrRef) {
|
when (proposalOrRef) {
|
||||||
@@ -1731,17 +1667,16 @@ class MlsGroup private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIP-03 authorization & admin-depletion gates on inbound commits
|
// The policy's gate on inbound commits, mirroring what `commit()`
|
||||||
// (mirror what `commit()` enforces locally — without these a peer
|
// enforces locally. Without it a peer could send us anything its own
|
||||||
// could send us a non-admin GCE rename, a non-admin Remove, or a
|
// copy of the rules would have refused and we would silently apply
|
||||||
// commit that empties `admin_pubkeys` and we'd silently apply it).
|
// 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,
|
// 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
|
// so the admin lookup is moot, and an external joiner can't include
|
||||||
// arbitrary proposals — only Add/Remove/PSK/ExternalInit per
|
// arbitrary proposals — only Add/Remove/PSK/ExternalInit per
|
||||||
// RFC 9420 §12.4.3.2.
|
// RFC 9420 §12.4.3.2.
|
||||||
if (!isExternalCommit) {
|
if (!isExternalCommit) {
|
||||||
enforceAuthorizedProposalSet(resolvedPending, committerLeafIndex = senderLeafIndex)
|
policy.authorizeCommit(view(), resolvedPending, senderLeafIndex)
|
||||||
enforceNoAdminDepletion(resolvedPending)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the resolved proposals. Matches the committer's order: apply
|
// 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.
|
* MLS-Exporter function for deriving application-specific keys.
|
||||||
*
|
*
|
||||||
* Marmot uses:
|
* Marmot, for instance, derives the outer ChaCha20-Poly1305 key for its
|
||||||
* exporterSecret("marmot", "group-event".toByteArray(), 32)
|
* GroupEvents with label "marmot" and context "group-event" — see
|
||||||
* to derive the outer ChaCha20-Poly1305 key for GroupEvents.
|
* [MlsGroupPolicy.commitExporter], which is how the engine reaches it
|
||||||
|
* without naming any one binding.
|
||||||
*/
|
*/
|
||||||
fun exporterSecret(
|
fun exporterSecret(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -2075,19 +2011,6 @@ class MlsGroup private constructor(
|
|||||||
length: Int,
|
length: Int,
|
||||||
): ByteArray = KeySchedule.mlsExporter(epochSecrets.exporterSecret, label, context, length)
|
): 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) ---
|
// --- 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 {
|
fun isCommitAuthorized(pubMsg: PublicMessage): Boolean {
|
||||||
val proposals = resolveCommitProposals(pubMsg) ?: return false
|
val proposals = resolveCommitProposals(pubMsg) ?: return false
|
||||||
return try {
|
return try {
|
||||||
enforceAuthorizedProposalSet(proposals, committerLeafIndex = pubMsg.sender.leafIndex)
|
policy.authorizeCommit(view(), proposals, pubMsg.sender.leafIndex)
|
||||||
enforceNoAdminDepletion(proposals)
|
|
||||||
true
|
true
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
@@ -2664,126 +2586,6 @@ class MlsGroup private constructor(
|
|||||||
return tree.addLeaf(leafNode)
|
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(
|
private fun applyProposal(
|
||||||
proposal: Proposal,
|
proposal: Proposal,
|
||||||
senderLeafIndex: Int,
|
senderLeafIndex: Int,
|
||||||
@@ -2824,7 +2626,7 @@ class MlsGroup private constructor(
|
|||||||
is Proposal.GroupContextExtensions -> {
|
is Proposal.GroupContextExtensions -> {
|
||||||
// Validate extension types are supported (RFC 9420 Section 12.1.7)
|
// Validate extension types are supported (RFC 9420 Section 12.1.7)
|
||||||
for (ext in proposal.extensions) {
|
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}"
|
"Unsupported extension type: ${ext.extensionType}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3208,7 +3010,7 @@ class MlsGroup private constructor(
|
|||||||
// (0x0002 is ratchet_tree — putting it here makes GroupContext
|
// (0x0002 is ratchet_tree — putting it here makes GroupContext
|
||||||
// unreadable to OpenMLS/MDK, which type-validates extensions by
|
// unreadable to OpenMLS/MDK, which type-validates extensions by
|
||||||
// context.)
|
// 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.
|
// RFC 9420 §13.3 IANA registry: 0x0004 is external_pub.
|
||||||
// (0x0003 is required_capabilities — using it here makes
|
// (0x0003 is required_capabilities — using it here makes
|
||||||
@@ -3217,10 +3019,10 @@ class MlsGroup private constructor(
|
|||||||
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
|
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
|
||||||
|
|
||||||
/** MLS self_remove proposal type (MIP-00 / MIP-03). */
|
/** 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. */
|
/** 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. */
|
/** How far back a fresh KeyPackage LeafNode's `not_before` is set. */
|
||||||
private const val LIFETIME_SKEW_SECONDS = 3_600L
|
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
|
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
|
* Extension types RFC 9420 and the drafts we implement define.
|
||||||
|
*
|
||||||
/** Known extension types that this implementation accepts. */
|
* A binding's own types come from [MlsGroupPolicy.knownExtensionTypes]
|
||||||
|
* and are unioned with this at the point of use.
|
||||||
|
*/
|
||||||
private val KNOWN_EXTENSION_TYPES =
|
private val KNOWN_EXTENSION_TYPES =
|
||||||
setOf(
|
setOf(
|
||||||
RATCHET_TREE_EXTENSION_TYPE,
|
RATCHET_TREE_EXTENSION_TYPE,
|
||||||
REQUIRED_CAPABILITIES_EXTENSION_TYPE,
|
REQUIRED_CAPABILITIES_EXTENSION_TYPE,
|
||||||
EXTERNAL_PUB_EXTENSION_TYPE,
|
EXTERNAL_PUB_EXTENSION_TYPE,
|
||||||
EXTERNAL_SENDERS_EXTENSION_TYPE,
|
EXTERNAL_SENDERS_EXTENSION_TYPE,
|
||||||
MARMOT_GROUP_DATA_EXTENSION_TYPE,
|
|
||||||
// The current profile's carrier for all app-owned group state.
|
// The current profile's carrier for all app-owned group state.
|
||||||
// A group can arrive at one either by being created with it or
|
// A group can arrive at one either by being created with it or
|
||||||
// by a GroupContextExtensions proposal that installs it.
|
// by a GroupContextExtensions proposal that installs it.
|
||||||
AppDataDictionary.EXTENSION_TYPE,
|
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.
|
* Parsed view of the RFC 9420 §7.2 `required_capabilities` extension.
|
||||||
*
|
*
|
||||||
@@ -3427,107 +3203,6 @@ class MlsGroup private constructor(
|
|||||||
return null
|
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).
|
* Create a new MLS group with a single member (the creator).
|
||||||
*/
|
*/
|
||||||
@@ -3542,13 +3217,19 @@ class MlsGroup private constructor(
|
|||||||
* added later by a proposal.
|
* added later by a proposal.
|
||||||
*/
|
*/
|
||||||
leafExtensions: List<Extension> = emptyList(),
|
leafExtensions: List<Extension> = emptyList(),
|
||||||
capabilities: Capabilities = marmotLeafCapabilities(),
|
|
||||||
/**
|
/**
|
||||||
* The `required_capabilities` extension for epoch 0. Defaults to
|
* The application's rules for this group. Also supplies the
|
||||||
* the MIP-era set; a current-profile group passes
|
* defaults below, so one argument selects a whole profile.
|
||||||
* [buildCurrentProfileRequiredCapabilitiesExtension].
|
|
||||||
*/
|
*/
|
||||||
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 {
|
): MlsGroup {
|
||||||
val sigKp =
|
val sigKp =
|
||||||
signingKey?.let { key ->
|
signingKey?.let { key ->
|
||||||
@@ -3574,11 +3255,11 @@ class MlsGroup private constructor(
|
|||||||
tree.setLeaf(0, leafNode)
|
tree.setLeaf(0, leafNode)
|
||||||
|
|
||||||
val treeHash = tree.treeHash()
|
val treeHash = tree.treeHash()
|
||||||
// Start with required_capabilities + whatever the caller wants to
|
// Start with required_capabilities, when the profile has any, plus
|
||||||
// bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so
|
// whatever the caller wants baked into epoch 0 — typically the
|
||||||
// new peers who join later can see the group name without first
|
// binding's own group-metadata extension, so a later joiner can read
|
||||||
// decrypting a pre-membership bootstrap commit — see MIP-03).
|
// it without first decrypting a pre-membership bootstrap commit.
|
||||||
val baseExtensions = listOf(requiredCapabilities)
|
val baseExtensions = listOfNotNull(requiredCapabilities)
|
||||||
val groupContext =
|
val groupContext =
|
||||||
GroupContext(
|
GroupContext(
|
||||||
groupId = groupId,
|
groupId = groupId,
|
||||||
@@ -3607,6 +3288,7 @@ class MlsGroup private constructor(
|
|||||||
signingPrivateKey = sigKp.privateKey,
|
signingPrivateKey = sigKp.privateKey,
|
||||||
encryptionPrivateKey = encKp.privateKey,
|
encryptionPrivateKey = encKp.privateKey,
|
||||||
interimTranscriptHash = ByteArray(0),
|
interimTranscriptHash = ByteArray(0),
|
||||||
|
policy = policy,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3619,6 +3301,7 @@ class MlsGroup private constructor(
|
|||||||
fun processWelcome(
|
fun processWelcome(
|
||||||
welcomeBytes: ByteArray,
|
welcomeBytes: ByteArray,
|
||||||
bundle: KeyPackageBundle,
|
bundle: KeyPackageBundle,
|
||||||
|
policy: MlsGroupPolicy = MlsGroupPolicy.Permissive,
|
||||||
): MlsGroup {
|
): MlsGroup {
|
||||||
val mlsMsg = MlsMessage.decodeTls(TlsReader(welcomeBytes))
|
val mlsMsg = MlsMessage.decodeTls(TlsReader(welcomeBytes))
|
||||||
require(mlsMsg.wireFormat == WireFormat.WELCOME) { "Expected Welcome message" }
|
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
|
// must advertise. MLS cannot enforce it, so a joiner that skipped
|
||||||
// this check would join a group it can never satisfy and have
|
// this check would join a group it can never satisfy and have
|
||||||
// every one of its commits refused by peers that do check.
|
// 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)
|
// Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3)
|
||||||
// For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh)
|
// For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh)
|
||||||
@@ -3835,6 +3526,7 @@ class MlsGroup private constructor(
|
|||||||
signingPrivateKey = bundle.signaturePrivateKey,
|
signingPrivateKey = bundle.signaturePrivateKey,
|
||||||
encryptionPrivateKey = bundle.encryptionPrivateKey,
|
encryptionPrivateKey = bundle.encryptionPrivateKey,
|
||||||
interimTranscriptHash = interimTranscriptHash,
|
interimTranscriptHash = interimTranscriptHash,
|
||||||
|
policy = policy,
|
||||||
)
|
)
|
||||||
groupSecrets.pathSecret?.let { pathSecret ->
|
groupSecrets.pathSecret?.let { pathSecret ->
|
||||||
val ancestorIdx = joined.directPathIndexOfAncestorWith(groupInfo.signer)
|
val ancestorIdx = joined.directPathIndexOfAncestorWith(groupInfo.signer)
|
||||||
@@ -3868,7 +3560,8 @@ class MlsGroup private constructor(
|
|||||||
groupInfoBytes: ByteArray,
|
groupInfoBytes: ByteArray,
|
||||||
identity: ByteArray,
|
identity: ByteArray,
|
||||||
signingKey: ByteArray? = null,
|
signingKey: ByteArray? = null,
|
||||||
capabilities: Capabilities = marmotLeafCapabilities(),
|
policy: MlsGroupPolicy = MlsGroupPolicy.Permissive,
|
||||||
|
capabilities: Capabilities = policy.defaultLeafCapabilities,
|
||||||
leafExtensions: List<Extension> = emptyList(),
|
leafExtensions: List<Extension> = emptyList(),
|
||||||
): ExternalJoinResult {
|
): ExternalJoinResult {
|
||||||
val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes))
|
val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes))
|
||||||
@@ -4098,6 +3791,7 @@ class MlsGroup private constructor(
|
|||||||
signingPrivateKey = sigKp.privateKey,
|
signingPrivateKey = sigKp.privateKey,
|
||||||
encryptionPrivateKey = encKp.privateKey,
|
encryptionPrivateKey = encKp.privateKey,
|
||||||
interimTranscriptHash = interimTranscriptHash,
|
interimTranscriptHash = interimTranscriptHash,
|
||||||
|
policy = policy,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wrap the commit in a PublicMessage envelope so existing members
|
// 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
|
* or senders we never decrypted) simply re-derive from generation 0 on
|
||||||
* first use — safe, because those messages were already processed.
|
* 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 tree = RatchetTree.decodeTls(TlsReader(state.treeBytes))
|
||||||
val secretTree = SecretTree(state.encryptionSecret, tree.leafCount)
|
val secretTree = SecretTree(state.encryptionSecret, tree.leafCount)
|
||||||
secretTree.importSenderStates(state.senderRatchetStates)
|
secretTree.importSenderStates(state.senderRatchetStates)
|
||||||
@@ -4161,6 +3858,7 @@ class MlsGroup private constructor(
|
|||||||
interimTranscriptHash = state.interimTranscriptHash,
|
interimTranscriptHash = state.interimTranscriptHash,
|
||||||
pathPrivateKeys = state.pathPrivateKeys.toMutableMap(),
|
pathPrivateKeys = state.pathPrivateKeys.toMutableMap(),
|
||||||
pendingProposals = state.pendingProposals.toMutableList(),
|
pendingProposals = state.pendingProposals.toMutableList(),
|
||||||
|
policy = policy,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4176,7 +3874,7 @@ class MlsGroup private constructor(
|
|||||||
groupId: ByteArray? = null,
|
groupId: ByteArray? = null,
|
||||||
leafIndex: Int? = null,
|
leafIndex: Int? = null,
|
||||||
parentHash: ByteArray? = null,
|
parentHash: ByteArray? = null,
|
||||||
capabilities: Capabilities = marmotLeafCapabilities(),
|
capabilities: Capabilities,
|
||||||
leafExtensions: List<Extension> = emptyList(),
|
leafExtensions: List<Extension> = emptyList(),
|
||||||
): LeafNode {
|
): LeafNode {
|
||||||
val unsigned =
|
val unsigned =
|
||||||
@@ -4268,12 +3966,10 @@ class MlsGroup private constructor(
|
|||||||
* return value is the epoch this message must be outer-encrypted under.
|
* return value is the epoch this message must be outer-encrypted under.
|
||||||
*/
|
*/
|
||||||
fun buildSelfRemoveProposalMessage(): Pair<ByteArray, ByteArray> {
|
fun buildSelfRemoveProposalMessage(): Pair<ByteArray, ByteArray> {
|
||||||
check(!isLocalAdmin()) {
|
policy.authorizeSelfRemove(view())
|
||||||
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
|
|
||||||
}
|
|
||||||
|
|
||||||
val preCommitExporterSecret =
|
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 proposal = Proposal.SelfRemove()
|
||||||
val proposalBytes = proposal.toTlsBytes()
|
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
|
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.TlsReader
|
||||||
import com.vitorpamplona.quartz.mls.codec.TlsSerializable
|
import com.vitorpamplona.quartz.mls.codec.TlsSerializable
|
||||||
import com.vitorpamplona.quartz.mls.codec.TlsWriter
|
import com.vitorpamplona.quartz.mls.codec.TlsWriter
|
||||||
import com.vitorpamplona.quartz.mls.components.AppDataDictionary
|
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.crypto.MlsCryptoProvider
|
||||||
import com.vitorpamplona.quartz.mls.tree.Extension
|
import com.vitorpamplona.quartz.mls.tree.Extension
|
||||||
import com.vitorpamplona.quartz.mls.tree.LeafNode
|
import com.vitorpamplona.quartz.mls.tree.LeafNode
|
||||||
@@ -93,7 +93,7 @@ data class MlsKeyPackage(
|
|||||||
*/
|
*/
|
||||||
fun isLastResort(): Boolean =
|
fun isLastResort(): Boolean =
|
||||||
extensions.any { it.extensionType == LAST_RESORT_EXTENSION_TYPE } ||
|
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.
|
* Encode the TBS (to-be-signed) portion for signature verification.
|
||||||
|
|||||||
+15
-13
@@ -20,7 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot
|
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.MlsGroupManager
|
||||||
|
import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin
|
||||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
@@ -57,7 +59,7 @@ class MarmotMipBehaviorTest {
|
|||||||
private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore())
|
private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore())
|
||||||
|
|
||||||
private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle {
|
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))
|
return tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ class MarmotMipBehaviorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun create_installsRequiredCapabilitiesExtension() {
|
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.
|
// RFC 9420 §13.3: required_capabilities is extension type 0x0003.
|
||||||
val reqCaps = alice.extensions.find { it.extensionType == 0x0003 }
|
val reqCaps = alice.extensions.find { it.extensionType == 0x0003 }
|
||||||
@@ -416,7 +418,7 @@ class MarmotMipBehaviorTest {
|
|||||||
)
|
)
|
||||||
val ex =
|
val ex =
|
||||||
assertFailsWith<IllegalStateException> {
|
assertFailsWith<IllegalStateException> {
|
||||||
alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 1)
|
MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 1)
|
||||||
}
|
}
|
||||||
assertTrue(
|
assertTrue(
|
||||||
ex.message!!.contains("non-admin members may only commit"),
|
ex.message!!.contains("non-admin members may only commit"),
|
||||||
@@ -454,7 +456,7 @@ class MarmotMipBehaviorTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Should not throw.
|
// Should not throw.
|
||||||
alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 0)
|
MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -488,7 +490,7 @@ class MarmotMipBehaviorTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertFailsWith<IllegalStateException> {
|
assertFailsWith<IllegalStateException> {
|
||||||
alice.enforceNoAdminDepletion(proposals)
|
MarmotGroupPolicy.enforceNoAdminDepletion(alice.view(), proposals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +641,7 @@ class MarmotMipBehaviorTest {
|
|||||||
val alice = manager.getGroup(groupId)!!
|
val alice = manager.getGroup(groupId)!!
|
||||||
val welcomeBytes =
|
val welcomeBytes =
|
||||||
requireNotNull(commitResult.welcomeBytes) { "addMember must produce a Welcome" }
|
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
|
return alice to bob
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +717,7 @@ class MarmotMipBehaviorTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun verifyTreeParentHashesForJoin_acceptsSingleMemberTree() {
|
fun verifyTreeParentHashesForJoin_acceptsSingleMemberTree() {
|
||||||
val alice = MlsGroup.create(aliceId.hexToByteArray())
|
val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy)
|
||||||
val tree =
|
val tree =
|
||||||
com.vitorpamplona.quartz.mls.tree.RatchetTree
|
com.vitorpamplona.quartz.mls.tree.RatchetTree
|
||||||
.decodeTls(
|
.decodeTls(
|
||||||
@@ -798,7 +800,7 @@ class MarmotMipBehaviorTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun findRequiredCapabilities_decodesMarmotExtensionInstalledByCreate() {
|
fun findRequiredCapabilities_decodesMarmotExtensionInstalledByCreate() {
|
||||||
val alice = MlsGroup.create(aliceId.hexToByteArray())
|
val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy)
|
||||||
val req =
|
val req =
|
||||||
MlsGroup.findRequiredCapabilities(alice.extensions)
|
MlsGroup.findRequiredCapabilities(alice.extensions)
|
||||||
?: error("required_capabilities must be present after create()")
|
?: error("required_capabilities must be present after create()")
|
||||||
@@ -906,7 +908,7 @@ class MarmotMipBehaviorTest {
|
|||||||
* validates. Useful for testing the §7.2 gate in isolation.
|
* validates. Useful for testing the §7.2 gate in isolation.
|
||||||
*/
|
*/
|
||||||
private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.mls.messages.MlsKeyPackage {
|
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 bundle = tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0))
|
||||||
val original = bundle.keyPackage
|
val original = bundle.keyPackage
|
||||||
val originalLeaf = original.leafNode
|
val originalLeaf = original.leafNode
|
||||||
@@ -947,7 +949,7 @@ class MarmotMipBehaviorTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun computePskSecret_emptyListReturnsAllZeros() {
|
fun computePskSecret_emptyListReturnsAllZeros() {
|
||||||
val alice = MlsGroup.create(aliceId.hexToByteArray())
|
val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy)
|
||||||
val out = alice.computePskSecret(emptyList())
|
val out = alice.computePskSecret(emptyList())
|
||||||
assertEquals(32, out.size, "psk_secret length must be Nh = 32 for SHA-256")
|
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")
|
assertTrue(out.all { it == 0.toByte() }, "default_psk_secret is all zeros")
|
||||||
@@ -970,7 +972,7 @@ class MarmotMipBehaviorTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun computePskSecret_singleExternalPsk_matchesSpecDerivation() {
|
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 pskId = ByteArray(16) { (it + 1).toByte() }
|
||||||
val pskNonce = ByteArray(16) { (0x80 or it).toByte() }
|
val pskNonce = ByteArray(16) { (0x80 or it).toByte() }
|
||||||
val pskValue = ByteArray(32) { (0xA0 or (it and 0x0F)).toByte() }
|
val pskValue = ByteArray(32) { (0xA0 or (it and 0x0F)).toByte() }
|
||||||
@@ -1017,7 +1019,7 @@ class MarmotMipBehaviorTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun computePskSecret_resumptionPskRejectsUntilProposalWidened() {
|
fun computePskSecret_resumptionPskRejectsUntilProposalWidened() {
|
||||||
val alice = MlsGroup.create(aliceId.hexToByteArray())
|
val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy)
|
||||||
val pskId = ByteArray(16) { it.toByte() }
|
val pskId = ByteArray(16) { it.toByte() }
|
||||||
alice.registerPsk(pskId, ByteArray(32))
|
alice.registerPsk(pskId, ByteArray(32))
|
||||||
|
|
||||||
@@ -1038,7 +1040,7 @@ class MarmotMipBehaviorTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun computePskSecret_orderingChangesOutput() {
|
fun computePskSecret_orderingChangesOutput() {
|
||||||
val alice = MlsGroup.create(aliceId.hexToByteArray())
|
val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy)
|
||||||
val idA = ByteArray(16) { 0x11 }
|
val idA = ByteArray(16) { 0x11 }
|
||||||
val idB = ByteArray(16) { 0x22 }
|
val idB = ByteArray(16) { 0x22 }
|
||||||
alice.registerPsk(idA, ByteArray(32) { 0x33 })
|
alice.registerPsk(idA, ByteArray(32) { 0x33 })
|
||||||
|
|||||||
+7
-2
@@ -20,6 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot.appComponents
|
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.components.AppDataDictionary
|
||||||
import com.vitorpamplona.quartz.mls.group.MlsGroup
|
import com.vitorpamplona.quartz.mls.group.MlsGroup
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
@@ -47,7 +52,7 @@ class CurrentProfileAuthorizationTest {
|
|||||||
creator: ByteArray,
|
creator: ByteArray,
|
||||||
admins: List<ByteArray>,
|
admins: List<ByteArray>,
|
||||||
): MlsGroup {
|
): MlsGroup {
|
||||||
val group = MlsGroup.create(creator)
|
val group = MlsGroup.create(creator, policy = MarmotGroupPolicy)
|
||||||
val dictionary =
|
val dictionary =
|
||||||
MarmotGroupState.buildDictionary(
|
MarmotGroupState.buildDictionary(
|
||||||
adminPolicy = AdminPolicyV1.of(admins),
|
adminPolicy = AdminPolicyV1.of(admins),
|
||||||
@@ -79,7 +84,7 @@ class CurrentProfileAuthorizationTest {
|
|||||||
val alice = currentProfileGroup(aliceAccount, listOf(aliceAccount))
|
val alice = currentProfileGroup(aliceAccount, listOf(aliceAccount))
|
||||||
val bobBundle = alice.createKeyPackage(bobAccount, ByteArray(0))
|
val bobBundle = alice.createKeyPackage(bobAccount, ByteArray(0))
|
||||||
val add = alice.addMember(bobBundle.keyPackage.toTlsBytes())
|
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())
|
assertTrue(!bob.isLocalAdmin())
|
||||||
assertEquals(setOf(aliceAccount.toHexKey()), bob.currentAdminIdentities())
|
assertEquals(setOf(aliceAccount.toHexKey()), bob.currentAdminIdentities())
|
||||||
|
|||||||
+3
@@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.marmot.appComponents
|
|||||||
import com.vitorpamplona.quartz.TestResourceLoader
|
import com.vitorpamplona.quartz.TestResourceLoader
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2
|
import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles
|
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.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
|
||||||
import com.vitorpamplona.quartz.mls.codec.TlsReader
|
import com.vitorpamplona.quartz.mls.codec.TlsReader
|
||||||
|
|||||||
+115
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot.groups
|
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.MlsGroupManager
|
||||||
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
|
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
|
||||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
@@ -147,7 +148,7 @@ class MlsGroupManagerTest {
|
|||||||
// group is enough to observe the ratchet behavior.)
|
// group is enough to observe the ratchet behavior.)
|
||||||
val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||||
val addResult = alice.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
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.
|
// Alice sends generation 0 (no commit); Bob consumes it.
|
||||||
val ct0 = alice.encrypt(groupId, "msg0".encodeToByteArray())
|
val ct0 = alice.encrypt(groupId, "msg0".encodeToByteArray())
|
||||||
@@ -314,7 +315,7 @@ class MlsGroupManagerTest {
|
|||||||
// production before a Welcome has ever been seen).
|
// production before a Welcome has ever been seen).
|
||||||
val bobBundle1 =
|
val bobBundle1 =
|
||||||
MlsGroup
|
MlsGroup
|
||||||
.create("bob".encodeToByteArray())
|
.create("bob".encodeToByteArray(), policy = MarmotGroupPolicy)
|
||||||
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||||
val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes())
|
val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes())
|
||||||
val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome")
|
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. -
|
// --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. -
|
||||||
val bobBundle2 =
|
val bobBundle2 =
|
||||||
MlsGroup
|
MlsGroup
|
||||||
.create("bob".encodeToByteArray())
|
.create("bob".encodeToByteArray(), policy = MarmotGroupPolicy)
|
||||||
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
|
||||||
val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes())
|
val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes())
|
||||||
val secondWelcome =
|
val secondWelcome =
|
||||||
|
|||||||
+13
-7
@@ -24,6 +24,12 @@ import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
|
|||||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1
|
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles
|
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.Ed25519
|
||||||
import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair
|
import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair
|
||||||
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
|
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
|
||||||
@@ -87,7 +93,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
val commit = group.commit()
|
val commit = group.commit()
|
||||||
val welcome = assertNotNull(commit.welcomeBytes, "adding a member must produce a Welcome")
|
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(nostrGroupId.toHexKey(), joined.currentNostrGroupId())
|
||||||
assertEquals(group.currentGroupState().profile?.name, joined.currentGroupState().profile?.name)
|
assertEquals(group.currentGroupState().profile?.name, joined.currentGroupState().profile?.name)
|
||||||
}
|
}
|
||||||
@@ -122,7 +128,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
||||||
val welcome = assertNotNull(group.commit().welcomeBytes)
|
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(
|
assertTrue(
|
||||||
failure.message.orEmpty().contains("agent text stream roles"),
|
failure.message.orEmpty().contains("agent text stream roles"),
|
||||||
"expected a role-capability refusal, got: ${failure.message}",
|
"expected a role-capability refusal, got: ${failure.message}",
|
||||||
@@ -151,7 +157,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
||||||
val welcome = assertNotNull(group.commit().welcomeBytes)
|
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())
|
assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
|
|
||||||
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
||||||
val welcome = assertNotNull(group.commit().welcomeBytes)
|
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())
|
assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +221,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
|
|
||||||
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
||||||
val welcome = assertNotNull(group.commit().welcomeBytes)
|
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(
|
assertTrue(
|
||||||
failure.message.orEmpty().contains("agent text stream roles"),
|
failure.message.orEmpty().contains("agent text stream roles"),
|
||||||
"expected a role-capability refusal, got: ${failure.message}",
|
"expected a role-capability refusal, got: ${failure.message}",
|
||||||
@@ -253,7 +259,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
): KeyPackageBundle {
|
): KeyPackageBundle {
|
||||||
val full = CurrentProfileGroupFactory.createKeyPackage(signer)
|
val full = CurrentProfileGroupFactory.createKeyPackage(signer)
|
||||||
val reduced =
|
val reduced =
|
||||||
MlsGroup.currentProfileLeafCapabilities().let {
|
MarmotCapabilities.currentProfileLeaf().let {
|
||||||
Capabilities(
|
Capabilities(
|
||||||
extensions = it.extensions + roles,
|
extensions = it.extensions + roles,
|
||||||
proposals = it.proposals,
|
proposals = it.proposals,
|
||||||
@@ -289,7 +295,7 @@ class CurrentProfileWelcomeTest {
|
|||||||
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
||||||
val welcome = assertNotNull(group.commit().welcomeBytes)
|
val welcome = assertNotNull(group.commit().welcomeBytes)
|
||||||
|
|
||||||
val joined = MlsGroup.processWelcome(welcome, invitee)
|
val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
AgentTextStreamQuicPolicyV1.userToAgentDefault(),
|
AgentTextStreamQuicPolicyV1.userToAgentDefault(),
|
||||||
joined.currentGroupState().agentTextStream,
|
joined.currentGroupState().agentTextStream,
|
||||||
|
|||||||
+145
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user