Merge pull request #2493 from vitorpamplona/claude/fix-marmot-interop-tests-3ZSSj

Fix MLS commit cryptography and add comprehensive validation
This commit is contained in:
Vitor Pamplona
2026-04-22 09:42:41 -04:00
committed by GitHub
30 changed files with 1639 additions and 333 deletions
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/**
@@ -95,11 +96,23 @@ suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult =
private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
try {
val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored
if (!MarmotInboundProcessor.isWelcomeEvent(inner) || inner !is WelcomeEvent) {
// NIP-59 wraps contain TWO encryption layers:
// kind:1059 gift wrap → kind:13 sealed rumor → the rumor itself.
// `GiftWrapEvent.unwrapOrNull` only peels the outer layer; when the
// result is a [SealedRumorEvent] we must unseal it to reach the
// kind:444 Welcome rumor. The old code checked `isWelcomeEvent` on
// the seal (kind:13) and always took the Ignored branch, which is
// why every inbound Welcome was silently dropped by the CLI and by
// any non-Amethyst consumer.
val rumor =
when (val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored) {
is SealedRumorEvent -> inner.unsealOrNull(signer) ?: return MarmotIngestResult.Ignored
else -> inner
}
if (!MarmotInboundProcessor.isWelcomeEvent(rumor) || rumor !is WelcomeEvent) {
return MarmotIngestResult.Ignored
}
when (val result = processWelcome(inner, inner.nostrGroupId())) {
when (val result = processWelcome(rumor, rumor.nostrGroupId())) {
is WelcomeResult.Joined -> {
MarmotIngestResult.JoinedGroup(
nostrGroupId = result.nostrGroupId,
@@ -262,11 +262,24 @@ class MarmotManager(
/**
* Create a new MLS group.
*
* When [initialMetadata] is non-null it is baked into epoch 0's
* GroupContext.extensions. Later joiners' welcomes therefore carry the
* group name / admin list / relays from the get-go and no separate
* "bootstrap commit" needs to be published. The bootstrap-commit path
* works in theory, but it produces a kind:445 encrypted with epoch 0's
* exporter secret that no post-membership peer (amethyst or wn) has,
* so each such peer wastes their commit-retry budget on an
* undecryptable event before processing the real state.
*/
suspend fun createGroup(nostrGroupId: HexKey): HexKey {
suspend fun createGroup(
nostrGroupId: HexKey,
initialMetadata: MarmotGroupData? = null,
): HexKey {
Log.d("MarmotManager") { "createGroup($nostrGroupId): by ${signer.pubKey.take(8)}" }
val identity = signer.pubKey.hexToByteArray()
groupManager.createGroup(nostrGroupId, identity)
val extras = initialMetadata?.let { listOf(it.toExtension()) } ?: emptyList()
groupManager.createGroup(nostrGroupId, identity, initialExtensions = extras)
subscriptionManager.subscribeGroup(nostrGroupId)
Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" }
return nostrGroupId
@@ -277,15 +290,18 @@ class MarmotManager(
* Returns proposal bytes to publish (as a GroupEvent).
*/
suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent {
// Build the outbound event BEFORE deleting group state (needs exporter secret)
val group =
groupManager.getGroup(nostrGroupId)
?: throw IllegalStateException("Not a member of group $nostrGroupId")
val proposalBytes = group.selfRemove()
val outboundEvent = outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes)
// leaveGroup() returns the framed standalone SelfRemove proposal
// (PublicMessage{Proposal}) plus the pre-commit exporter key for
// outer encryption. Runs BEFORE we tear down the subscriptions so
// the pre-commit exporter is still derivable.
val (framedBytes, exporterKey) = groupManager.leaveGroup(nostrGroupId)
val outboundEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = framedBytes,
exporterKey = exporterKey,
)
// Now clean up group state
groupManager.removeGroupState(nostrGroupId)
subscriptionManager.unsubscribeGroup(nostrGroupId)
try {
messageStore?.delete(nostrGroupId)
@@ -346,13 +362,24 @@ class MarmotManager(
/**
* Update group metadata (name, description, etc.) via a GroupContextExtensions proposal.
* Creates a GCE proposal, commits it, and returns the commit event to publish.
*
* RFC 9420 §12.1.7: a GroupContextExtensions proposal REPLACES the entire
* extension list in GroupContext. Peers (notably mdk-core / whitenoise-rs)
* reject welcomes whose GroupContext is missing the required-capabilities
* extension, and strip metadata from groups whose context lacks the
* MarmotGroupData extension. We therefore preserve every other existing
* extension and overwrite only the slot we actually want to update.
*/
suspend fun updateGroupMetadata(
nostrGroupId: HexKey,
metadata: MarmotGroupData,
): OutboundGroupEvent {
val commitResult =
groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension()))
val group =
groupManager.getGroup(nostrGroupId)
?: throw IllegalStateException("Not a member of group $nostrGroupId")
val preserved = group.extensions.filter { it.extensionType != MarmotGroupData.EXTENSION_ID_INT }
val merged = preserved + metadata.toExtension()
val commitResult = groupManager.updateGroupExtensions(nostrGroupId, merged)
val commitEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
@@ -480,6 +507,18 @@ class MarmotManager(
*/
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
/**
* Hex-encoded MLS group id for the group keyed by [nostrGroupId], or null if
* that group is not locally known.
*
* Interop note: whitenoise-rs (and every mdk consumer) indexes groups by the
* MLS GroupContext's groupId, NOT the MIP-01 nostr_group_id that we use as
* the primary key internally. When a harness or external caller needs to
* cross-reference a group with another client (e.g. the interop harness
* calling `wn messages list <mls_id>`), it needs this translation.
*/
fun mlsGroupIdHex(nostrGroupId: HexKey): HexKey? = groupManager.getGroup(nostrGroupId)?.groupId?.toHexKey()
/**
* Resolve the MLS leaf index for a member by Nostr pubkey, or null if that
* pubkey isn't currently in the group.