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:
+16
-3
@@ -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,
|
||||
|
||||
+51
-12
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user