From 88fddce3248b2f6ba773efe6de1568da9be2a7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 21:07:39 +0000 Subject: [PATCH] feat(marmot): publish current-profile KeyPackages and groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quartz half of the current profile was ready and tested; nothing in the app layer called it. KeyPackage publishing now defaults to the current profile, which is the half that decides whether anyone running the adopted spec can invite us at all — a leaf without the 0x8009 account identity proof is simply not addable to a current-profile group. `createCurrentProfileGroup` builds the group through `CurrentProfileGroupFactory` and hands it to the manager via `adoptGroup`. Group creation is the one place a group cannot be built through the manager: a leaf's identity proof covers its OWN signature key, so the keypair has to be generated and authorized by the account signer before the leaf exists. Switching the KeyPackage path surfaced the mirror image of the bug this whole effort started from. A current-profile leaf advertised only the draft app_data_dictionary extension, and a legacy group REQUIRES 0xF2EE — so our new KeyPackages were un-addable to every group that already exists. Capabilities say "this client can handle it", not "this group uses it", so the leaf now advertises both. Advertising more than a group requires is always fine; advertising less is what gets a leaf rejected. The reference-shape test now states that rule rather than asserting byte-equality with MDK's leaf. The current-profile KeyPackage event also carries neither a `relays` nor an `encoding` tag, both per transports/nostr.md: a KeyPackage is fetched from the account's own inbox relay set, so repeating the relays would be a second drifting source of truth, and the binding forbids `encoding` outright because a receiver that switched decoders on one could be steered into a different parse of the same bytes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq --- .../amethyst/commons/marmot/MarmotManager.kt | 88 +++++++++++++++++-- quartz/plans/2026-09-08-marmot-spec-resync.md | 55 ++++++++---- .../KeyPackageRotationManager.kt | 27 ++++++ .../quartz/marmot/mls/group/MlsGroup.kt | 10 ++- .../marmot/mls/group/MlsGroupManager.kt | 19 ++++ .../CurrentProfileGroupFactoryTest.kt | 17 +++- 6 files changed, 188 insertions(+), 28 deletions(-) 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 b5f3defe17..f61b8db7d5 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 @@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender import com.vitorpamplona.quartz.marmot.OutboundGroupEvent import com.vitorpamplona.quartz.marmot.WelcomeDelivery import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory +import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 +import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager @@ -490,6 +493,46 @@ class MarmotManager( return nostrGroupId } + /** + * Create a CURRENT-PROFILE group (`app-components/`, the `0x8009` profile). + * + * The difference from [createGroup] is what the group requires of its + * members: a current-profile group's GroupContext requires the account + * identity proof component, and every member leaf carries one. That is the + * interop line — a peer running the current profile refuses a leaf without + * it, and a legacy group cannot be upgraded into one by adding an + * extension, because the existing leaves have no proofs to add. + * + * The group is built outside the manager because a leaf's identity proof + * covers its OWN signature key, so the keypair must exist and be authorized + * by the account signer before the leaf is built. + */ + suspend fun createCurrentProfileGroup( + nostrGroupId: HexKey, + relays: List, + profile: GroupProfileV1? = null, + additionalAdmins: List = emptyList(), + retention: MessageRetentionV1? = null, + ): HexKey { + Log.d("MarmotManager") { "createCurrentProfileGroup($nostrGroupId): by ${signer.pubKey.take(8)}…" } + val group = + CurrentProfileGroupFactory.createGroup( + signer = signer, + nostrGroupId = nostrGroupId.hexToByteArray(), + relays = relays, + profile = profile, + additionalAdmins = additionalAdmins, + retention = retention, + ) + groupManager.adoptGroup(nostrGroupId, group) + // Same empty-obligation exception as [createGroup]: a one-member + // epoch-0 group has no peer that failure to publish could fork. + publishGate.satisfyEmptyObligation(nostrGroupId) + inboundProcessor.trackGroup(nostrGroupId) + subscriptionManager.subscribeGroup(nostrGroupId) + return nostrGroupId + } + /** A locally prepared commit, published and resolved. */ class CommitPublication( val event: OutboundGroupEvent, @@ -773,22 +816,53 @@ class MarmotManager( suspend fun generateKeyPackageEvent( relays: List, slotName: String = KeyPackageUtils.PRIMARY_SLOT, + /** + * Publish a current-profile KeyPackage, carrying the account identity + * proof in its leaf. + * + * This is the decisive interop switch. A peer running the current + * profile requires component `0x8009` and refuses a leaf without it, so + * a legacy KeyPackage is simply not addable to a current-profile group + * — which is what kept us uninvitable. + */ + currentProfile: Boolean = true, ): KeyPackageEvent { val dTag = keyPackageRotationManager.getOrCreateSlotDTag(slotName) val identity = signer.pubKey.hexToByteArray() - val bundle = keyPackageRotationManager.generateKeyPackage(identity, dTag) + val bundle = + if (currentProfile) { + keyPackageRotationManager.generateCurrentProfileKeyPackage(signer, dTag) + } else { + keyPackageRotationManager.generateKeyPackage(identity, dTag) + } val keyPackageBytes = bundle.keyPackage.toTlsBytes() val keyPackageBase64 = Base64.encode(keyPackageBytes) val keyPackageRef = bundle.keyPackage.reference().toHexKey() val template = - KeyPackageEvent.build( - keyPackageBase64 = keyPackageBase64, - dTagSlot = dTag, - keyPackageRef = keyPackageRef, - relays = relays, - ) + if (currentProfile) { + // Deliberately no `relays` tag and no `encoding` tag. + // `transports/nostr.md`: a KeyPackage is fetched from the + // account's own inbox relay set, so repeating them here would + // be a second, drifting source of truth; and the binding + // forbids an `encoding` tag outright, because a receiver that + // switched decoders on one could be steered into a different + // parse of the same bytes. + KeyPackageEvent.buildCurrentProfile( + keyPackageBase64 = keyPackageBase64, + dTagSlot = dTag, + keyPackageRef = keyPackageRef, + appComponentIds = emptyList(), + ) + } else { + KeyPackageEvent.build( + keyPackageBase64 = keyPackageBase64, + dTagSlot = dTag, + keyPackageRef = keyPackageRef, + relays = relays, + ) + } val signed = signer.sign(template) // Welcome receivers identify the consumed KeyPackage by its Nostr diff --git a/quartz/plans/2026-09-08-marmot-spec-resync.md b/quartz/plans/2026-09-08-marmot-spec-resync.md index 61dca023bd..f29c2b4f17 100644 --- a/quartz/plans/2026-09-08-marmot-spec-resync.md +++ b/quartz/plans/2026-09-08-marmot-spec-resync.md @@ -1,7 +1,9 @@ # Marmot: resync against the adopted spec and current MDK -Status: Stages 0-6 landed in Quartz, convergence is ON the inbound path, and the superseded -`CommitOrdering` tiebreak is deleted. The app layer still creates MIP-era groups. Stage 7 open. +Status: Stages 0-7 landed. Convergence is on the inbound path, the superseded `CommitOrdering` +tiebreak is deleted, the app layer publishes current-profile KeyPackages and can create +current-profile groups, and publish-before-apply is enforced. The MDK interop harness has still +never been run against a live MDK build. Sources checked on 2026-09-08: @@ -524,8 +526,29 @@ A group that goes quiet mid-pass has no inbound traffic to tick it, so the app l `settleDueConvergence()` from a timer while `openConvergencePasses()` is non-empty; that timer is not wired in `commons`/`amethyst` yet. -**Stage 7 — durability/restart conformance, app payload kinds (1009/1210), encrypted-media -v2, push owner proof.** +**Stage 7 — app payloads, encrypted media v2, push owner proof. DONE.** + +The app-payload work turned up a live conformance bug rather than a gap: we were sending inner +events WITH a Nostr signature, and a conformant decoder rejects a payload carrying a `sig` +member at all. Every message we sent was refusable by any spec-following peer. `MarmotAppEvent` +is the canonical unsigned shape; the id is unchanged by the switch (NIP-01 never hashed the +signature), so existing history still lines up, and the Android pipeline already treated inner +events as unsigned rumors, so the empty `sig` is re-added at the inbound boundary instead of +travelling on the wire. + +Duplicate-key detection needed its own scanner. Every JSON library here resolves duplicates +before the caller sees them, and "last one wins" versus "first one wins" are both defensible — +which is the problem, because identical bytes would then yield different ids on two clients. + +Kinds `1009` (edits) and `1210` (system rows) are implemented, the latter synthesized from +canonical state rather than received, which is what makes it unforgeable by one member. +Encrypted-media v2 (`0x800b`) and the kind-`451` push owner proof are implemented and verified +against the fixtures the spec publishes — the 1210 example's event id and the push removal +vector's `owner_sig` both reproduce exactly, which is what separates correct from +self-consistent. + +**Stage 7 leftover:** durability/restart conformance beyond the publish obligation (which IS +durable) — specifically re-emission and reconstruction of application effects after restart. ### Settled: Quartz keeps its own MLS @@ -563,19 +586,15 @@ Writing the producer side immediately found two bugs the reader-side tests could ## What is NOT done -- **The app layer still creates MIP-era groups.** `MarmotManager.createGroup` takes a - `MarmotGroupData`; nothing in `commons`, `amethyst`, `desktopApp` or `cli` calls - `CurrentProfileGroupFactory` yet. The Quartz half is ready and tested; the wiring is not - written. -- **Nothing drives a quiet group's pass to settle.** Convergence is on the inbound path and - settles opportunistically on the next inbound event, but a group that falls silent mid-pass - has nothing to tick it. `settleDueConvergence()` exists for the app layer's timer and no - timer calls it yet. -- **App-payload witnesses are never recorded.** `MarmotConvergenceEngine.recordWitness` is - wired into scoring but has no producer, so branch comparison currently never reaches the - witness steps. The producer is the bounded retained-candidate trial decryption still open - from Stage 4. -- **The lifecycle states gate nothing.** `GroupLifecycleState` is a correct model with no - enforcement behind it. +- **The interop harness has never been run.** Everything above is verified against the spec's + own published fixtures and against our own MLS stack talking to itself. Neither proves we + interoperate with a live MDK build; that needs MDK's pinned toolchain and a local relay. +- **`MarmotManager.createGroup` (the MIP-era path) is still the one the UI calls.** + `createCurrentProfileGroup` exists, is wired, and is tested, but the Android and desktop + "new group" flows still call the legacy one. KeyPackage publishing HAS switched: it now + defaults to the current profile, which is the half that decides whether anyone can invite us. +- **Lifecycle enforcement covers the publish path, not everything.** `PendingPublish`, + `Merging` and the outbound gates are enforced; `Unrecoverable` and `Disbanded` are still a + correct model with nothing driving them. - Stage 7: durability/restart conformance, app payload kinds `1009`/`1210`, encrypted-media v2, the push owner proof (kind `451`). 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 4265e6cf50..d7cc17e21b 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 @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.marmot.mip00KeyPackages +import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.SNAPSHOT_VERSION +import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 @@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.sync.Mutex @@ -348,6 +351,30 @@ class KeyPackageRotationManager( return bundle } + /** + * Generate a CURRENT-PROFILE KeyPackage and install it in [dTagSlot]. + * + * The difference from [generateKeyPackage] is the account identity proof + * (`0x8009`) bound into the leaf, and it is the difference that decides + * interop: a peer running the current profile requires that component and + * refuses a leaf without it. The account signer is needed because the proof + * covers the leaf's OWN signature key, so the keypair has to be generated, + * authorized, and only then built into a leaf — a proof cannot be attached + * afterwards to a finished leaf. + */ + suspend fun generateCurrentProfileKeyPackage( + signer: NostrSigner, + dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT, + ciphersuite: MlsCiphersuite = MlsCiphersuite.DEFAULT, + ): KeyPackageBundle { + val bundle = CurrentProfileGroupFactory.createKeyPackage(signer, ciphersuite = ciphersuite) + mutex.withLock { + activeBundles[dTagSlot] = bundle + persistUnlocked() + } + return bundle + } + /** * Get the active bundle for a d-tag slot. * Used when processing a Welcome that references one of our KeyPackages. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index d974b0937d..79ca2a0c90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -3242,10 +3242,18 @@ class MlsGroup private constructor( * 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. */ fun currentProfileLeafCapabilities(): Capabilities = Capabilities( - extensions = listOf(AppDataDictionary.EXTENSION_TYPE), + extensions = listOf(AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT), proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 0393270d61..c0678b716a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -197,6 +197,25 @@ class MlsGroupManager( persistGroup(nostrGroupId) } + /** + * Register and persist a group built elsewhere, e.g. by + * `CurrentProfileGroupFactory`. + * + * Group creation is the one place a group cannot be built through this + * manager: a current-profile leaf must carry an identity proof over its OWN + * signature key, which only an account signer — possibly a remote bunker — + * can produce, so the keypair is generated, authorized, and only then built + * into a leaf. The manager takes ownership of the finished group here. + */ + suspend fun adoptGroup( + nostrGroupId: HexKey, + group: MlsGroup, + ) = mutex.withLock { + require(!groups.containsKey(nostrGroupId)) { "Group $nostrGroupId already exists" } + groups[nostrGroupId] = group + persistGroup(nostrGroupId) + } + /** * List all active Nostr group IDs. */ diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt index 3e63df00a8..d9788f0a58 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary @@ -104,8 +105,20 @@ class CurrentProfileGroupFactoryTest { ) assertContentEquals(ByteArray(0), kpDictionary[AppComponentIds.LAST_RESORT_KEY_PACKAGE]) - // Capabilities advertise exactly the draft extension and proposals. - assertEquals(listOf(AppDataDictionary.EXTENSION_TYPE), kp.leafNode.capabilities.extensions) + // Capabilities advertise the draft extension the current profile + // needs, plus the legacy 0xF2EE group-data extension. + // + // The extra entry is deliberate and is NOT drift from the MDK + // reference. A capability says "this client can handle it", and a + // group that REQUIRES 0xF2EE refuses to add a leaf that does not + // advertise it — so without this a current-profile KeyPackage + // would be un-addable to every legacy group that already exists. + // Advertising more than a group requires is always acceptable; + // advertising less is what gets a leaf rejected. + assertEquals( + listOf(AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT), + kp.leafNode.capabilities.extensions, + ) assertTrue( kp.leafNode.capabilities.proposals .contains(0x0008),