feat(marmot): publish current-profile KeyPackages and groups

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-08 21:07:39 +00:00
parent 1ff2bcc198
commit 88fddce324
6 changed files with 188 additions and 28 deletions
@@ -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<String>,
profile: GroupProfileV1? = null,
additionalAdmins: List<ByteArray> = 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<NormalizedRelayUrl>,
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<KeyPackageEvent>(template)
// Welcome receivers identify the consumed KeyPackage by its Nostr
+37 -18
View File
@@ -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`).
@@ -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.
@@ -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),
)
@@ -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.
*/
@@ -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),