From d25b36704de1d53174dc322ae7ed39699efe883e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:32:05 +0000 Subject: [PATCH] docs(mls): record the engine boundary; last hardcoded profile bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three leftovers from the extraction, plus the documentation it needs to survive. `KeyPackageRotationManager` still built its leaf capabilities from bare hex (`0x000A`, `0xF2EE`, `0x000A`) with the two meanings of `0x000A` -- last_resort as an extension, self_remove as a proposal -- distinguishable only by which list they were in. That set is now `MarmotCapabilities.mipKeyPackageLeaf()`, beside the other two profiles, with the ordering marked load-bearing: those bytes go into published KeyPackages and define KeyPackageBundleStore's v4 snapshot format. `MarmotManager` and `MarmotConvergenceEngine` still spelled `exporterSecret("marmot", "group-event", 32)` literally at three call sites, so the binding's key derivation was stated in four places. They read it off `MarmotGroupPolicy.commitExporter` now, which is where the engine reads it too. `CurrentProfileWelcomeTest` and `CommitPreservesLeafIdentityTest` sat in the `mls/` test tree with ten and three Marmot imports between them; they test Marmot's current profile, so they move to `marmot/appComponents/`. The README states the invariant as a command you can run, and scopes it honestly: shipped code only. Two tests under `mls/components/` do decode real Marmot components, because an interop test is worth more against payloads that actually exist -- a fixture is data, an import in the engine is a dependency. It also writes down the three things a second binding has to know, the one that will bite (a policy is behaviour, not state, so a restore that forgets it silently drops the rules), and why the default is permissive rather than closed. The plan's Stage 1 is marked landed with what it cost and what Stage 3 still owes: MlsGroupManager names `nostrGroupId` 147 times, so cordn needs its own manager over the same MlsGroup rather than reusing that one. §5.1's coupling measurements are marked superseded rather than deleted -- they are what the stage was scoped against. Verified: :quartz:jvmTest 5082, :commons:jvmTest 2202, both green; :quic, :cli, :marmotBench and :amethyst all compile. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../amethyst/cli/stores/FileStores.kt | 2 +- .../amethyst/commons/marmot/MarmotManager.kt | 13 +-- .../marmot/MarmotAgentStreamWatcherTest.kt | 4 +- .../MarmotKeyPackageRotationProfileTest.kt | 4 +- .../marmot/MarmotManagerLeaveRejoinTest.kt | 4 +- .../marmot/MarmotManagerRestoreTest.kt | 4 +- .../marmot/MarmotPublishDurabilityTest.kt | 2 +- .../commons/marmot/MarmotTestStores.kt | 2 +- .../com/vitorpamplona/marmotbench/Fixtures.kt | 2 +- quartz/plans/2026-09-17-cordn-interop.md | 83 +++++++++++------ .../marmot/groups/MarmotCapabilities.kt | 19 ++++ .../KeyPackageRotationManager.kt | 15 +--- .../protocolCore/MarmotConvergenceEngine.kt | 4 +- .../com/vitorpamplona/quartz/mls/README.md | 89 +++++++++++++++++++ .../CommitPreservesLeafIdentityTest.kt | 3 +- .../CurrentProfileWelcomeTest.kt | 3 +- 17 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md rename quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/{mls/group => marmot/appComponents}/CommitPreservesLeafIdentityTest.kt (98%) rename quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/{mls/group => marmot/appComponents}/CurrentProfileWelcomeTest.kt (99%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 251c86a90e..a1cd0a305d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -207,8 +207,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.image import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt index dfd9a9f0bd..a14dacb75c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.cli.stores import com.vitorpamplona.amethyst.cli.SecureFileIO import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 85dfc3861b..1fc15e29e5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotGroupSnapshot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore @@ -90,14 +91,14 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.sha256.sha256 -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi /** * Central coordinator for Marmot MLS group messaging. @@ -327,9 +328,11 @@ class MarmotManager( ): ByteArray? = try { val preCommitKey = - MlsGroup - .restore(obligation.priorState) - .exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MarmotGroupPolicy.commitExporter.let { exporter -> + MlsGroup + .restore(obligation.priorState, MarmotGroupPolicy) + .exporterSecret(exporter.label, exporter.context, exporter.length) + } GroupEventEncryption.decrypt(event.content, preCommitKey) } catch (e: Exception) { Log.w( diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt index 46c8199cf7..8f89009f85 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStat import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicException import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicStream import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CompletableDeferred diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt index fa0559a990..bd33a29b0d 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt index 9a4e698a3f..427810d0f8 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt index f0eea22f23..c1fc52ea02 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt index f40b90cbce..dc592a16c5 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt index 6bf03cf957..0d0d65d075 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stand-ins for the durable stores a MarmotManager needs. diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt index aeb258a366..50b256ffaa 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.marmotbench import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stores, matching the commons test doubles byte for byte. diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md index d604b2f68a..47a17e5415 100644 --- a/quartz/plans/2026-09-17-cordn-interop.md +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -1,9 +1,10 @@ # Cordn interop: extract the MLS core, then add a second binding -Status: Stage 2 landed. `:contextvm` implements the core spec plus all 12 CEPs on the client -side, with the Tier C fixture server, at 172 tests. Stages 0 (cordn-side vectors), 1 (extract the -MLS engine) and 3-4 (the cordn binding and app integration) are open. Stage 3 is the one gated on -the §4.1 upstream decision. +Status: Stages 1 and 2 landed. The RFC 9420 engine is `quartz/…/mls/` and imports nothing from +`marmot/` — a binding supplies its rules through `MlsGroupPolicy`. `:contextvm` implements the +core spec plus all 12 CEPs on the client side, with the Tier C fixture server, at 172 tests. +Stages 0 (cordn-side vectors) and 3-4 (the cordn binding and app integration) are open; Stage 3 +is the one gated on the §4.1 upstream decision. Correction to an earlier gate in this plan: §4.1 does **not** block Stage 2. ContextVM is credential-agnostic and has no MLS dependency at all, so the transport was safe to build first; @@ -201,7 +202,11 @@ wire format. There is nothing to implement against. Do not attempt it. ### 5.1 The engine, and how Marmot-clean it is -`quartz/…/marmot/mls/` is **11,964 LOC**. Measured coupling to the Marmot layer: +**Superseded by Stage 1, which landed.** The measurements below are what the engine looked like +before the extraction; they are kept because they are what the stage was scoped against. The +engine is now `quartz/…/mls/` with zero `marmot/` imports. + +`quartz/…/marmot/mls/` was **11,964 LOC**. Measured coupling to the Marmot layer: - **3 files**, **10 imports** total: - `group/MlsGroup.kt` (7): `MarmotGroupData`, `MarmotGroupState`, `AdminPolicyV1`, @@ -637,29 +642,57 @@ No production code. Two deliverables. **Gate:** do not start Stage 2 until §4.1 has an answer. Stage 1 is safe to do regardless. -### Stage 1 — Extract a binding-agnostic RFC 9420 engine +### Stage 1 — Extract a binding-agnostic RFC 9420 engine — LANDED -Worth doing whether or not cordn ever ships. Today the engine is one protocol's private detail; -this makes it a library. +Done in four commits. The engine is `quartz/…/mls/` and imports nothing from `quartz/…/marmot/`. -- Move `quartz/…/marmot/mls/` → `quartz/…/mls/`. Packages change; `marmot/` keeps everything - else. -- Parameterize what §5.1 lists as hardcoded: group id, credential identity bytes, - `required_capabilities`, leaf `capabilities`, and the exporter label/context all become - constructor or call-site inputs. `exporterSecret(label, context, length)` already exists — - stop calling it with a literal `"marmot"` from inside the engine. -- Push the Marmot-specific reads out to `marmot/`: `currentMarmotData()`, `currentGroupState()`, - `currentNostrGroupId()`, the `AdminPolicyV1`/`GroupLifecycleV1` hooks and the agent-text-stream - helpers. Where the engine needs a policy decision, it takes an interface; `marmot/` supplies the - Marmot implementation. -- Move `group/MarmotMessageStore.kt` out (it is named for Marmot and keyed on - `nostrGroupId` — it is a binding concern). -- Behaviour must not change. The existing MLS + Marmot test suites are the contract; they pass - unmodified except for import lines. +| What | Where it went | +| ---- | ------------- | +| The engine (28 files, 11,964 LOC) | `quartz/…/marmot/mls/` → `quartz/…/mls/` | +| `MlsGroupManager`, `MlsGroupStateStore`, `MarmotMessageStore` | → `marmot/groups/` (all keyed on `nostrGroupId`) | +| MIP-03 authorization, depletion guard, join role check, self-remove gate | → `marmot/groups/MarmotGroupPolicy` | +| Leaf + `required_capabilities` profiles | → `marmot/groups/MarmotCapabilities` | +| `currentMarmotData/GroupState/NostrGroupId`, `agentTextStreamSecret` | → `marmot/groups/MarmotGroupViews` (extension functions) | +| `last_resort_key_package` (0x0004) | → `mls/components/ComponentsList` — it is the extensions draft's, not Marmot's | -Risk: `MlsGroup.kt` is 4,505 lines and carries the convergence/lifecycle logic. Keep this stage -strictly mechanical — extraction and parameterization, no logic edits — so the diff stays -reviewable and the test suite is a real check. +**The seam is `MlsGroupPolicy`**: 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). One argument selects a whole profile — `MlsGroup.create(id, policy = MarmotGroupPolicy)` +brings the rules, the capabilities and `MLS-Exporter("marmot", "group-event", 32)` together — so +adopting it cost one added argument per call site rather than five. + +Policies receive a read-only `GroupView`, not the `MlsGroup`: a policy holding the group could +commit or rotate keys from inside the check meant to gate those things. + +**The default is permissive**, which is a trade worth naming. 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 deliberately not in `MlsGroupState`. All ten production +construction sites are in `marmot/` and all ten name it. + +Two findings from doing it: + +1. **`:quic` was already a second consumer, reaching through the wrong package.** + `quic/tls/TlsClient.kt` imported `quartz.marmot.mls.crypto.X25519` for its TLS 1.3 handshake — + neither Marmot nor MLS. That is the argument for this stage independent of cordn. +2. **The test suite found every site that had been relying on Marmot defaults.** The first run + after `MlsGroup.create` stopped defaulting to Marmot's profile failed 13 tests, each one a + Marmot test that had been getting a Marmot-shaped group for free. The other ~180 construction + sites kept passing on the permissive default — they are engine tests, and they now prove the + engine runs without Marmot at all. + +8 tests were added for the seam itself (`MlsGroupPolicySeamTest`, `MarmotPolicySeamTest`), +including the half no existing test covered: 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. + +Suite: `:quartz:jvmTest` 5074 → 5082, `:commons:jvmTest` 2202, both green. + +What Stage 3 still owes: `MlsGroupManager` is keyed on `nostrGroupId` 147 times, so cordn cannot +reuse it and needs its own manager over the same `MlsGroup`. Class names were left alone in the +move — `MlsGroupManager` under `marmot/groups/` reads correctly and renaming four classes would +have churned 22 files across five modules for clarity the package path already gives. ### Stage 2 — `:contextvm` module (clean-room) — LANDED diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt index a0c1ae5abd..8f16cce811 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt @@ -25,6 +25,7 @@ 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.messages.MlsKeyPackage import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.mls.tree.Extension @@ -59,6 +60,24 @@ object MarmotCapabilities { proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), ) + /** + * The MIP-era leaf set as a published KeyPackage advertises it. + * + * Same as [mipLeaf] plus `0x000A` as an EXTENSION, which OpenMLS validation + * requires on a last-resort KeyPackage. Note `0x000A` appears in both lists + * meaning different things: as an extension it is `last_resort`, as a + * proposal it is `self_remove`. + * + * The order is load-bearing and must not be tidied: these bytes go into + * published KeyPackages, and `KeyPackageBundleStore`'s v4 snapshot format + * is defined by them. + */ + fun mipKeyPackageLeaf(): Capabilities = + Capabilities( + extensions = listOf(MlsKeyPackage.LAST_RESORT_EXTENSION_TYPE, 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): diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index 387738cbf8..1999dfe1ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.SNAPSHOT_VERSION import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.mls.codec.TlsReader @@ -30,7 +31,6 @@ import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.mls.crypto.X25519 import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.mls.tree.LeafNode @@ -654,18 +654,7 @@ class KeyPackageRotationManager( encryptionKey = encryptionKey, signatureKey = signatureKey, credential = Credential.Basic(identity), - capabilities = - Capabilities( - extensions = - listOf( - 0x000A, // LastResort (required by OpenMLS validation) - 0xF2EE, // NostrGroupData (required by group's RequiredCapabilities) - ), - proposals = - listOf( - 0x000A, // SelfRemove (required by group's RequiredCapabilities) - ), - ), + capabilities = MarmotCapabilities.mipKeyPackageLeaf(), leafNodeSource = LeafNodeSource.KEY_PACKAGE, lifetime = Lifetime(notBefore = now, notAfter = now + KEY_PACKAGE_LIFETIME_SECONDS), extensions = emptyList(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt index f67bbd93b0..bd2111dede 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt @@ -423,7 +423,9 @@ class MarmotConvergenceEngine( mutex.withLock { contexts[groupId]?.candidateStates?.values?.mapNotNull { state -> try { - MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MarmotGroupPolicy.commitExporter.let { + MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret(it.label, it.context, it.length) + } } catch (_: Exception) { null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md new file mode 100644 index 0000000000..17e85c0ffd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md @@ -0,0 +1,89 @@ +# `mls/` — the RFC 9420 engine + +A binding-agnostic MLS (RFC 9420) implementation: TLS presentation-language +codec, HPKE, X25519/Ed25519, the ratchet tree, the key schedule, the secret +tree, message framing, proposals, commits, Welcome, and the `app_data_dictionary` +component carrier from `draft-ietf-mls-extensions-10`. + +**It knows nothing about Marmot, Nostr, or cordn, and must stay that way.** The +invariant, over the shipped code: + +```bash +grep -rn 'import com.vitorpamplona.quartz.marmot' \ + --include='*.kt' quartz/src/{commonMain,jvmAndroid,appleMain,linuxMain}/kotlin/com/vitorpamplona/quartz/mls/ +``` + +That must print nothing. Tests are deliberately outside it: a couple under +`mls/components/` decode real Marmot components as fixtures, because the point +of an interop test is to exercise the engine against payloads that actually +exist. A fixture is data; an import in the engine is a dependency. + +It lived under `marmot/` until Stage 1 of +`quartz/plans/2026-09-17-cordn-interop.md`, and `:quic` was already importing +`crypto/X25519` from there for its TLS 1.3 handshake — a use that is neither +Marmot nor MLS, and the reason the extraction was overdue. + +## Using it + +```kotlin +val group = MlsGroup.create(identity) // a plain RFC 9420 group +val group = MlsGroup.create(identity, policy = MarmotGroupPolicy) // Marmot's profile +``` + +The default group requires nothing beyond RFC 9420: no `required_capabilities`, +no leaf capabilities (§7.2 forbids advertising DEFAULT types), no commit +exporter, and no limit on who may commit what. + +## The `MlsGroupPolicy` seam + +RFC 9420 says who may *send* a proposal and never who may *commit* one. Real +deployments need more — Marmot's MIP-03 names admin accounts and allows everyone +else only a self-Update or a SelfRemove — so the engine asks a policy wherever +the spec defers to the application: + +| Hook | Called from | Marmot uses it for | +| ---- | ----------- | ------------------ | +| `authorizeCommit` | `commit()` and both inbound commit paths | MIP-03 proposal-set rules + admin depletion | +| `authorizeSelfRemove` | `proposeSelfRemove`, `buildSelfRemoveProposalMessage` | admin must self-demote first | +| `validateJoin` | `processWelcome` | `required_member_roles` on the agent-text-stream component | +| `defaultLeafCapabilities` | every leaf the engine builds | `0xF2EE` + `self_remove` | +| `defaultRequiredCapabilities` | `create()`, epoch 0 | the MIP-era interop set | +| `knownExtensionTypes` | GroupContextExtensions validation | `0xF2EE` | +| `commitExporter` | `CommitResult.preCommitExporterSecret` | `MLS-Exporter("marmot", "group-event", 32)` | + +Three things to know before you add a binding: + +1. **One argument selects a whole profile.** `policy` supplies the rules *and* + the capability defaults *and* the exporter binding, so `capabilities` and + `requiredCapabilities` default from it. Adopting a profile is one argument, + not five. + +2. **A policy is behaviour, not state.** It is deliberately absent from + `MlsGroupState`, so whatever restores a group must pass the same policy it + was created with. A restore that forgets it gets a group that silently skips + the binding's rules. In Marmot only `MlsGroupManager` restores a group in + order to commit with it, which is what keeps that narrow. + +3. **A policy gets a `GroupView`, not the `MlsGroup`.** A policy holding the + group could commit or rotate keys from inside the check meant to gate exactly + that. + +The default is permissive rather than closed. Closed would make the engine +unusable without a policy and would push callers into writing an +allow-everything one anyway; open puts each restriction in the binding that +documents it. The cost is item 2 above. + +## Where the bindings live + +- `quartz/…/marmot/groups/` — `MarmotGroupPolicy`, `MarmotCapabilities`, + `MarmotGroupViews` (Marmot's reads of a GroupContext, as extension functions), + plus `MlsGroupManager` and the two stores, all keyed on `nostrGroupId`. +- `quartz/…/marmot/mipXX…/` — the Nostr event layer. + +## Tests + +`quartz/src/commonTest/…/mls/` runs everywhere; `quartz/src/jvmAndroidTest/…/mls/` +holds the tests needing real secp256k1/JNI. `mls/interop/` replays the RFC 9420 +test vectors. `group/MlsGroupPolicySeamTest` pins the seam itself with a +recording policy; `marmot/groups/MarmotPolicySeamTest` pins that Marmot's rules +travel with `MarmotGroupPolicy` and not with the engine. diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CommitPreservesLeafIdentityTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CommitPreservesLeafIdentityTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt index 2c651bd466..13938086c2 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CommitPreservesLeafIdentityTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt @@ -18,12 +18,13 @@ * 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 +package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt similarity index 99% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt index 3ee36933a8..07f9b46bbf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/CurrentProfileWelcomeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.marmot.groups.currentMarmotData import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId import com.vitorpamplona.quartz.mls.crypto.Ed25519 import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray