⏺ fix: derive nostrGroupId from MLS GroupContext in Welcome processing

The "h" tag is optional in MIP-02 Welcome events — senders like
  whitenoise-rs omit it. Instead of failing when the h-tag is absent,
  derive nostrGroupId from the NostrGroupData extension embedded in the
  Welcome's GroupContext (the authoritative MLS source). An h-tag hint,
  if present, is still validated against the MLS-derived value.
This commit is contained in:
Vitor Pamplona
2026-04-20 12:32:42 -04:00
parent 9766cc5901
commit e0094f163b
4 changed files with 42 additions and 37 deletions
@@ -369,14 +369,14 @@ private suspend fun processMarmotWelcomeFlow(
return return
} }
val nostrGroupId = innerEvent.nostrGroupId() // "h" tag is optional per MIP-02 — some senders (e.g. whitenoise-rs) omit it.
if (nostrGroupId == null) { // nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead.
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: WelcomeEvent missing 'h' tag (nostrGroupId)" } val hintNostrGroupId = innerEvent.nostrGroupId()
return Log.d("MarmotDbg") {
"processMarmotWelcomeFlow: h-tag=${hintNostrGroupId?.take(8) ?: "(absent)"} — deriving from MLS content"
} }
Log.d("MarmotDbg") { "processMarmotWelcomeFlow: invoking manager.processWelcome group=${nostrGroupId.take(8)}" }
val result = manager.processWelcome(innerEvent, nostrGroupId) val result = manager.processWelcome(innerEvent, hintNostrGroupId)
when (result) { when (result) {
is WelcomeResult.Joined -> { is WelcomeResult.Joined -> {
@@ -128,20 +128,14 @@ class MarmotManager(
*/ */
suspend fun processWelcome( suspend fun processWelcome(
welcomeEvent: WelcomeEvent, welcomeEvent: WelcomeEvent,
nostrGroupId: HexKey, hintNostrGroupId: HexKey? = welcomeEvent.nostrGroupId(),
): WelcomeResult { ): WelcomeResult {
// Validate that the provided nostrGroupId matches the WelcomeEvent's h-tag if present // nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension.
val eventGroupId = welcomeEvent.nostrGroupId() // The h-tag value (hintNostrGroupId) is validated against the MLS content inside
if (eventGroupId != null && eventGroupId != nostrGroupId) { // inboundProcessor, so senders that omit the h-tag are handled transparently.
return WelcomeResult.Error( val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId)
"nostrGroupId mismatch: expected $nostrGroupId but WelcomeEvent has $eventGroupId",
)
}
val result = inboundProcessor.processWelcome(welcomeEvent, nostrGroupId)
if (result is WelcomeResult.Joined) { if (result is WelcomeResult.Joined) {
// Update subscription state for the new group
subscriptionManager.subscribeGroup(result.nostrGroupId) subscriptionManager.subscribeGroup(result.nostrGroupId)
Log.d("MarmotManager", "Joined group ${result.nostrGroupId}") Log.d("MarmotManager", "Joined group ${result.nostrGroupId}")
} }
@@ -219,26 +219,21 @@ class MarmotInboundProcessor(
* 4. Mark KeyPackage as consumed for rotation * 4. Mark KeyPackage as consumed for rotation
* *
* @param welcomeEvent the unwrapped kind:444 event * @param welcomeEvent the unwrapped kind:444 event
* @param nostrGroupId the Nostr group ID (from relay context or Welcome tags) * @param hintNostrGroupId optional group ID from the "h" tag; validated against MLS content
* if provided. If absent (sender omitted "h" tag), the ID is derived from the Welcome's
* NostrGroupData extension the MLS content is always the authoritative source.
* @return the processing result * @return the processing result
*/ */
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
suspend fun processWelcome( suspend fun processWelcome(
welcomeEvent: WelcomeEvent, welcomeEvent: WelcomeEvent,
nostrGroupId: HexKey, hintNostrGroupId: HexKey? = null,
): WelcomeResult = ): WelcomeResult =
try { try {
com.vitorpamplona.quartz.utils.Log com.vitorpamplona.quartz.utils.Log
.d("MarmotDbg") { .d("MarmotDbg") {
"MarmotInboundProcessor.processWelcome: group=${nostrGroupId.take(8)} eventId=${welcomeEvent.id.take(8)}" "MarmotInboundProcessor.processWelcome: hint=${hintNostrGroupId?.take(8)} eventId=${welcomeEvent.id.take(8)}"
} }
// Validate the caller-provided nostrGroupId matches the Welcome event's own h tag
val eventGroupId = welcomeEvent.nostrGroupId()
if (eventGroupId != null && eventGroupId != nostrGroupId) {
return WelcomeResult.Error(
"nostrGroupId mismatch: caller=$nostrGroupId, event=$eventGroupId",
)
}
val welcomeBytes = Base64.decode(welcomeEvent.welcomeBase64()) val welcomeBytes = Base64.decode(welcomeEvent.welcomeBase64())
val keyPackageEventId = welcomeEvent.keyPackageEventId() val keyPackageEventId = welcomeEvent.keyPackageEventId()
@@ -273,10 +268,11 @@ class MarmotInboundProcessor(
com.vitorpamplona.quartz.utils.Log com.vitorpamplona.quartz.utils.Log
.d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: bundle found — invoking groupManager.processWelcome" } .d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: bundle found — invoking groupManager.processWelcome" }
// Join the group // Join the group; nostrGroupId is derived from the MLS GroupContext's
groupManager.processWelcome(nostrGroupId, welcomeBytes, bundle) // NostrGroupData extension. The h-tag hint (if any) is validated inside.
val (_, nostrGroupId) = groupManager.processWelcome(welcomeBytes, bundle, hintNostrGroupId)
com.vitorpamplona.quartz.utils.Log com.vitorpamplona.quartz.utils.Log
.d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: groupManager.processWelcome succeeded for ${nostrGroupId.take(8)}" } .d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: joined group=${nostrGroupId.take(8)}" }
// Mark the KeyPackage as consumed — triggers rotation // Mark the KeyPackage as consumed — triggers rotation
keyPackageRotationManager.markConsumedByEventId(keyPackageEventId) keyPackageRotationManager.markConsumedByEventId(keyPackageEventId)
@@ -192,25 +192,40 @@ class MlsGroupManager(
* 2. Group state is persisted * 2. Group state is persisted
* 3. A KeyPackage rotation should be triggered (see [needsKeyPackageRotation]) * 3. A KeyPackage rotation should be triggered (see [needsKeyPackageRotation])
* *
* @param nostrGroupId hex-encoded Nostr group ID (from Welcome event tags)
* @param welcomeBytes TLS-serialized Welcome message * @param welcomeBytes TLS-serialized Welcome message
* @param bundle the KeyPackageBundle that was used for the invitation * @param bundle the KeyPackageBundle that was used for the invitation
* @return the joined [MlsGroup] * @param hintNostrGroupId optional nostrGroupId from the Welcome event's "h" tag;
* if provided and non-null, validated against the GroupContext's NostrGroupData extension.
* If absent (sender did not include an "h" tag), the ID is derived from the MLS content.
* @return pair of (joined group, derived nostrGroupId)
*/ */
suspend fun processWelcome( suspend fun processWelcome(
nostrGroupId: HexKey,
welcomeBytes: ByteArray, welcomeBytes: ByteArray,
bundle: KeyPackageBundle, bundle: KeyPackageBundle,
): MlsGroup = hintNostrGroupId: HexKey? = null,
): Pair<MlsGroup, HexKey> =
mutex.withLock { mutex.withLock {
val group = MlsGroup.processWelcome(welcomeBytes, bundle) val group = MlsGroup.processWelcome(welcomeBytes, bundle)
groups[nostrGroupId] = group
val derivedId =
group.currentMarmotData()?.nostrGroupId
?: throw IllegalArgumentException(
"Welcome GroupContext is missing the NostrGroupData extension — cannot derive nostrGroupId",
)
if (hintNostrGroupId != null && hintNostrGroupId != derivedId) {
throw IllegalArgumentException(
"nostrGroupId mismatch: h-tag=$hintNostrGroupId, GroupContext=$derivedId",
)
}
groups[derivedId] = group
// init_key is consumed — the bundle's initPrivateKey should not be // init_key is consumed — the bundle's initPrivateKey should not be
// reused. Caller must discard the bundle and rotate KeyPackages. // reused. Caller must discard the bundle and rotate KeyPackages.
persistGroup(nostrGroupId) persistGroup(derivedId)
group Pair(group, derivedId)
} }
/** /**