Merge pull request #2536 from vitorpamplona/claude/review-marmot-implementation-BSrzC

Add leave/rejoin, reactions, deletions, and Marmot reset functionality
This commit is contained in:
Vitor Pamplona
2026-04-23 19:00:24 -04:00
committed by GitHub
16 changed files with 1169 additions and 38 deletions
@@ -2334,6 +2334,29 @@ class Account(
client.publish(outbound.signedEvent, groupRelays)
}
/**
* User-initiated "nuclear" reset for the Marmot subsystem.
*
* Wipes every MLS group, every retained epoch secret, every persisted
* KeyPackage bundle, every relay subscription and every in-memory
* chatroom associated with this account. Does NOT broadcast any
* SelfRemove/leave commits to peers — if the user is in this flow at
* all, local state may already be unusable and a graceful leave is
* probably not possible. Peers will see the user as unresponsive until
* their next commit evicts the stale leaf.
*
* A fresh KeyPackage will be republished lazily on the next
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
* reachable for future group invites.
*/
suspend fun resetMarmotState() {
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${signer.pubKey.take(8)}" }
marmotManager?.resetAllState()
for (groupId in marmotGroupList.allGroupIds()) {
marmotGroupList.removeGroup(groupId)
}
}
/**
* Remove a member from a Marmot MLS group.
* Publishes the commit GroupEvent to group relays.
@@ -1536,6 +1536,10 @@ class AccountViewModel(
account.leaveMarmotGroup(nostrGroupId, relays)
}
suspend fun resetMarmotState() {
account.resetMarmotState()
}
/**
* Get the relay set for a Marmot group from MLS GroupContext metadata.
* Falls back to outbox relays if the group has no configured relays.
@@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
@@ -213,6 +214,17 @@ fun MarmotGroupInfoScreen(
// Members list (scrollable, takes remaining vertical space)
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (groupRelays.isNotEmpty()) {
item {
RelayHealthSection(
relayUrls = groupRelays,
relayActivity = relayActivity,
nav = nav,
)
HorizontalDivider()
}
}
item {
Text(
text = "Members",
@@ -784,3 +796,101 @@ fun GroupRelayTile(
}
}
}
/**
* Detailed per-relay listing showing URL, connection-status dot, and the
* freshness of the most recent kind:445 group event we've received from
* each relay. Complements the compact [GroupRelayStrip] at the top of the
* screen that one is an at-a-glance visual; this one surfaces the
* underlying data so users can diagnose which relay is lagging.
*/
@Composable
fun RelayHealthSection(
relayUrls: List<String>,
relayActivity: Map<NormalizedRelayUrl, Long>,
nav: INav,
) {
val normalized =
remember(relayUrls) {
relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } }
}
if (normalized.isEmpty()) return
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Relays",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
val nowSeconds = System.currentTimeMillis() / 1000L
normalized.forEach { (raw, relay) ->
val lastSeen = relayActivity[relay]
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
RelayHealthRow(
relay = relay,
fallbackUrl = raw,
lastSeen = lastSeen,
isActive = isActive,
nav = nav,
)
}
}
}
@Composable
private fun RelayHealthRow(
relay: NormalizedRelayUrl,
fallbackUrl: String,
lastSeen: Long?,
isActive: Boolean,
nav: INav,
) {
val context = LocalContext.current
val dotColor =
if (isActive) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.placeholderText
}
val subtitle =
if (lastSeen == null) {
"no events yet"
} else {
"last event${timeAgo(lastSeen, context)}"
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.RelayInfo(relay.url)) }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
Column(
modifier =
Modifier
.padding(start = 12.dp)
.weight(1f),
) {
Text(
text = relay.url.ifEmpty { fallbackUrl },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -28,16 +29,27 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -54,6 +66,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Preview
@Composable
@@ -72,6 +86,10 @@ fun AllSettingsScreen(
nav: INav,
) {
val tint = MaterialTheme.colorScheme.onBackground
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showResetMarmotDialog by remember { mutableStateOf(false) }
var isResettingMarmot by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -232,8 +250,85 @@ fun AllSettingsScreen(
tint = tint,
onClick = { nav.nav(Route.VanishEvents) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.reset_marmot_state,
icon = MaterialSymbols.DeleteSweep,
tint = tint,
onClick = { if (!isResettingMarmot) showResetMarmotDialog = true },
)
}
}
if (showResetMarmotDialog) {
ResetMarmotStateDialog(
onConfirm = {
showResetMarmotDialog = false
isResettingMarmot = true
scope.launch(Dispatchers.IO) {
val successMessage = stringRes(context, R.string.reset_marmot_success)
try {
accountViewModel.resetMarmotState()
launch(Dispatchers.Main) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
val failureMessage =
stringRes(context, R.string.reset_marmot_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
}
} finally {
isResettingMarmot = false
}
}
},
onDismiss = { showResetMarmotDialog = false },
)
}
}
@Composable
private fun ResetMarmotStateDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp),
)
},
title = {
Text(
text = stringRes(R.string.reset_marmot_confirm_title),
textAlign = TextAlign.Center,
)
},
text = {
Text(text = stringRes(R.string.reset_marmot_confirm_body))
},
confirmButton = {
Button(
onClick = onConfirm,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.reset_marmot_confirm_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@Composable
+6
View File
@@ -1122,6 +1122,12 @@
<string name="account_settings">Account Settings</string>
<string name="app_settings">App Settings</string>
<string name="danger_zone">Danger Zone</string>
<string name="reset_marmot_state">Reset Marmot State</string>
<string name="reset_marmot_confirm_title">Reset Marmot State?</string>
<string name="reset_marmot_confirm_body">This will permanently delete every Marmot group chat, message history, and MLS key on this device for the current account. Peers will not be notified and may still see you in groups until their next commit. This cannot be undone. A new KeyPackage will be published the next time the app syncs.</string>
<string name="reset_marmot_confirm_action">Reset</string>
<string name="reset_marmot_success">Marmot state reset.</string>
<string name="reset_marmot_failure">Failed to reset Marmot state: %1$s</string>
<string name="connectivity_type_always">Always</string>
<string name="connectivity_type_wifi_only">Wifi-only</string>
<string name="connectivity_type_unmetered_wifi_only">Unmetered WiFi</string>
@@ -166,6 +166,10 @@ private suspend fun marmotDispatch(
Commands.await(dataDir, rest)
}
"reset" -> {
Commands.reset(dataDir, rest)
}
else -> {
System.err.println("unknown marmot subcommand: $head")
printUsage()
@@ -224,6 +228,8 @@ private fun printUsage() {
|
| marmot message send GID TEXT publish kind:9 inner event into the group
| marmot message list GID [--limit N] dump decrypted inner events
| marmot message react GID EVENT_ID EMOJI publish kind:7 reaction targeting an inner event
| marmot message delete GID EVENT_ID publish kind:5 deletion targeting inner events
|
| marmot await key-package NPUB (all await verbs take --timeout SECS, default 30;
| marmot await group --name NAME exit 124 on timeout)
@@ -232,6 +238,8 @@ private fun printUsage() {
| marmot await message GID --match TEXT
| marmot await rename GID --name NAME
| marmot await epoch GID --min N
|
| marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive)
""".trimMargin(),
)
}
@@ -71,6 +71,11 @@ object Commands {
tail: Array<String>,
): Int = AwaitCommands.dispatch(dataDir, tail)
suspend fun reset(
dataDir: DataDir,
tail: Array<String>,
): Int = MarmotResetCommand.run(dataDir, tail)
suspend fun dm(
dataDir: DataDir,
tail: Array<String>,
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
/**
* `amy marmot reset [--yes]` wipe all local Marmot state.
*
* Mirrors the Android "Reset Marmot State" danger-zone action. Deletes
* every MLS group, every retained epoch secret, every persisted
* KeyPackage bundle, every active relay subscription, and the run-state
* since-cursors that track catch-up progress.
*
* Does NOT publish any SelfRemove/leave commits the reset path is
* specifically for recovering from corrupted or unrecoverable local
* state where graceful teardown may be impossible.
*
* Requires `--yes` to execute, because the command is destructive and
* cannot be undone. Without `--yes` the command prints the set of
* groups that would be wiped and exits with code 2.
*/
object MarmotResetCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val confirmed = rest.any { it == "--yes" || it == "-y" }
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val groupIds =
ctx.marmot
.activeGroupIds()
.toList()
.sorted()
if (!confirmed) {
Json.writeLine(
mapOf(
"dry_run" to true,
"would_wipe_groups" to groupIds,
"detail" to "pass --yes to actually reset",
),
)
return 2
}
ctx.marmot.resetAllState()
ctx.state.giftWrapSince = null
ctx.state.groupSince.clear()
Json.writeLine(
mapOf(
"reset" to true,
"wiped_groups" to groupIds,
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -25,17 +25,20 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip01Core.core.Event
object MessageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list> …")
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list|react|delete> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"send" -> send(dataDir, rest)
"list" -> list(dataDir, rest)
"react" -> react(dataDir, rest)
"delete" -> delete(dataDir, rest)
else -> Json.error("bad_args", "message ${tail[0]}")
}
}
@@ -111,4 +114,96 @@ object MessageCommands {
ctx.close()
}
}
private suspend fun react(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 3) return Json.error("bad_args", "message react <gid> <target_event_id> <emoji>")
val targetId = rest[1]
val emoji = rest[2]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Json.error("not_found", targetId)
val bundle = ctx.marmot.buildReactionMessage(gid, target, emoji)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"inner_event_id" to bundle.innerEvent.id,
"outer_event_id" to bundle.outbound.signedEvent.id,
"kind" to bundle.innerEvent.kind,
"target_event_id" to target.id,
"reaction" to emoji,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun delete(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "message delete <gid> <target_event_id> [target_event_id ...]")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val targetIds = rest.drop(1)
val targets =
targetIds.map { id ->
findStoredInnerEvent(ctx, gid, id) ?: return Json.error("not_found", id)
}
val bundle = ctx.marmot.buildDeletionMessage(gid, targets)
val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, relays)
Json.writeLine(
mapOf(
"group_id" to gid,
"inner_event_id" to bundle.innerEvent.id,
"outer_event_id" to bundle.outbound.signedEvent.id,
"kind" to bundle.innerEvent.kind,
"target_event_ids" to targets.map { it.id },
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
/**
* Scan the group's persisted inner-message log for an event matching
* [targetId] and reconstruct the full `Event`. Required by `react` and
* `delete` because the NIP-25 / NIP-09 templates need the target's
* `pubKey` + `kind` for p-tag / k-tag, not just the id.
*/
private suspend fun findStoredInnerEvent(
ctx: Context,
nostrGroupId: String,
targetId: String,
): Event? {
for (line in ctx.marmot.loadStoredMessages(nostrGroupId)) {
val parsed = Event.fromJsonOrNull(line) ?: continue
if (parsed.id == targetId) return parsed
}
return null
}
}
+152 -32
View File
@@ -37,13 +37,30 @@ test_09_reply_react_unreact() {
amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
record_result "$id" fail "amy send reply failed"; return
}
if wait_for_message B "$mls_gid" "replying via amy" 90; then
if ! wait_for_message B "$mls_gid" "replying via amy" 90; then
record_result "$id" fail "B didn't receive reply"; return
fi
# amy react — look up the anchor id from amy's own decrypted log (B's kind:9
# "anchor for reactions" was delivered + persisted during wait_for_message),
# then hand that id to the new react verb.
sleep 3
local a_anchor_id
a_anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq -r '[.messages[]? | select((.content // "") == "anchor for reactions")][0].id // empty')
if [[ -z "$a_anchor_id" || "$a_anchor_id" == "null" ]]; then
record_result "$id" fail "amy couldn't find anchor message in local log"; return
fi
if ! amy_json marmot message react "$gid" "$a_anchor_id" "🍕" >/dev/null; then
record_result "$id" fail "amy marmot message react failed"; return
fi
# Round-trip: B should surface amy's kind:7 reaction in its messages stream.
if wait_for_message B "$mls_gid" "🍕" 90; then
record_result "$id" pass
else
record_result "$id" fail "B didn't receive reply"
record_result "$id" fail "B didn't receive amy's reaction"
fi
# NB: react/unreact round-trip verification requires a CLI verb we don't have
# yet (amy marmot message react). Once we add it, expand this test.
}
test_10_concurrent_commits() {
@@ -191,44 +208,147 @@ test_14_wn_removes_a() {
banner "Test 14 — wn (admin) removes A; amy processes Remove"
local id="14 wn removes amy"
# Known gap: wn (mdk-core/openmls) emits the filtered direct-path
# form of UpdatePath on Remove commits per RFC 9420 §7.7 — when the
# copath of a direct-path node has an empty resolution (every leaf
# under it is blank) the corresponding UpdatePathNode is omitted.
# Quartz's RatchetTree.applyUpdatePath currently requires the
# unfiltered node count (`pathNodes.size == directPath.size`) so
# every wn->amy Remove triggers
# "UpdatePath node count (N) doesn't match direct path length (N+k)".
# That's a pre-existing quartz conformance bug, out of scope for
# this branch; the harness carries the test so it starts passing
# the moment the filtered-path path is wired up.
record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath"
# Exercises inbound filtered-direct-path per RFC 9420 §7.7: wn
# (mdk-core/openmls) strips UpdatePathNodes whose copath has empty
# resolution, and amy must accept the short path on receive. Quartz
# wires this on both sides as of commit 3279c246.
local out mls_gid
out=$(wn_b --json groups create "Interop-14" "$C_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn create Interop-14 failed"; return
}
mls_gid=$(printf '%s' "$out" | jq_group_id)
[[ -n "$mls_gid" ]] || { record_result "$id" fail "no group_id from create"; return; }
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
wn_b groups add-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn add-members A failed"; return
}
local a_gid
a_gid=$(amy_json marmot await group --name "Interop-14" --timeout 30 | jq -r '.group_id // empty')
[[ -n "$a_gid" ]] || { record_result "$id" fail "A never received Interop-14 invite"; return; }
# B (admin) removes A. The resulting commit carries a filtered
# UpdatePath; amy must apply it without throwing on the node-count
# mismatch that used to happen pre-filter-support.
wn_b groups remove-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn remove-members A failed"; return
}
# Amy should observe that she's no longer a member. `group show` returns
# a not_member error as soon as the Remove commit is applied locally.
local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do
local show rc
show=$(amy_json marmot group show "$a_gid" 2>&1)
rc=$?
if [[ $rc -ne 0 ]] && printf '%s' "$show" | jq -e '.error == "not_member"' >/dev/null 2>&1; then
removed=1; break
fi
sleep 3
done
if [[ "$removed" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy still considered herself a member after wn Remove"
fi
}
test_15_wn_member_leaves() {
banner "Test 15 — wn_c leaves; amy + wn_b process SelfRemove"
local id="15 wn_c leaves"
# Same filtered_direct_path gap as test 14: when wn_c leaves a
# 3-member group, wn_b folds the SelfRemove into a commit whose
# UpdatePath uses RFC 9420 §7.7 filtering, and amy's strict
# applyUpdatePath rejects it. Skip until quartz handles the
# filtered form on inbound.
record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath"
# Same filtered-direct-path path as test 14, but the trigger is a
# SelfRemove proposal from C that wn_b folds into a commit. Amy stays
# a member here — verification is that the commit applies cleanly and
# the group continues to function afterwards.
local out mls_gid
out=$(wn_b --json groups create "Interop-15" "$C_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn create Interop-15 failed"; return
}
mls_gid=$(printf '%s' "$out" | jq_group_id)
[[ -n "$mls_gid" ]] || { record_result "$id" fail "no group_id from create"; return; }
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
wn_b groups add-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn add-members A failed"; return
}
local a_gid
a_gid=$(amy_json marmot await group --name "Interop-15" --timeout 30 | jq -r '.group_id // empty')
[[ -n "$a_gid" ]] || { record_result "$id" fail "A never received Interop-15 invite"; return; }
# C self-removes. wn_b picks up the SelfRemove proposal and commits it.
wn_c groups leave "$mls_gid" >/dev/null 2>&1 || true
sleep 5
# Nudge B to commit any outstanding proposals via a no-op rename round-trip.
wn_b groups rename "$mls_gid" "Interop-15 (C left)" >/dev/null 2>&1 || true
# Wait for amy to see C gone AND stay a member herself.
local deadline=$(( $(date +%s) + 120 )) ok=0
while [[ $(date +%s) -lt $deadline ]]; do
local show
show=$(amy_json marmot group show "$a_gid" 2>/dev/null) || { sleep 3; continue; }
local c_still
c_still=$(printf '%s' "$show" | jq --arg p "$C_HEX" '[.members[]? | select(.pubkey == $p)] | length')
local a_still
a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select(.pubkey == $p)] | length')
if [[ "$c_still" == "0" && "$a_still" == "1" ]]; then
ok=1; break
fi
sleep 3
done
if [[ "$ok" -ne 1 ]]; then
record_result "$id" fail "amy never saw a (C gone, A present) state"; return
fi
# Post-commit round-trip: amy sends, B receives. Confirms encryption
# survived the filtered UpdatePath application on amy's side.
amy_json marmot message send "$a_gid" "after wn_c leave" >/dev/null || {
record_result "$id" fail "amy send failed after wn_c leave"; return
}
if wait_for_message B "$mls_gid" "after wn_c leave" 90; then
record_result "$id" pass
else
record_result "$id" fail "B didn't receive amy's post-leave message"
fi
}
test_16_wn_keypackage_rotation() {
banner "Test 16 — wn rotates KeyPackage; amy discovers new KP"
local id="16 wn keypackage rotation"
# amy's KeyPackageFetcher.fetchKeyPackage calls client.fetchFirst,
# which returns the first matching event a relay sends — nostr-rs-relay
# typically serves kind:443 events in storage order, not created_at
# order, so after a rotation amy may keep seeing the older event_id
# depending on which arrives first. Making this test deterministic
# requires a "fetch latest by created_at" KeyPackage fetcher; until
# then the check flaps. The inverse direction (test 13, amy rotates
# and wn sees via `wn keys check` which is an addressable index) is
# the reliable one.
record_result "$id" skip "pending createdAt-sorted KeyPackage fetch path"
# KeyPackageFetcher now drains to EOSE and returns the event with the
# highest created_at, so a freshly-rotated KP is reliably preferred
# over an older one still held on some relays. This test verifies
# the wn->amy direction of that behaviour.
# Capture the KP amy currently sees for B (the one B originally published).
local before
before=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null \
| jq -r '.event_id // empty')
if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP visible to amy for B"; return
fi
# Ask B to rotate. `wn keys publish` writes a new kind:443 with a fresh
# created_at; the old event may or may not be evicted depending on the
# relay's retention policy, so both may coexist for a while.
wn_b keys publish >/dev/null 2>&1 || {
record_result "$id" fail "wn_b keys publish failed"; return
}
local deadline=$(( $(date +%s) + 60 )) after=""
while [[ $(date +%s) -lt $deadline ]]; do
after=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null \
| jq -r '.event_id // empty')
[[ -n "$after" && "$after" != "$before" ]] && break
sleep 3
done
if [[ -n "$after" && "$after" != "$before" ]]; then
info "amy saw KP rotation: ${before:0:8}… → ${after:0:8}"
record_result "$id" pass
else
record_result "$id" fail "amy kept seeing the pre-rotation KP"
fi
}
@@ -0,0 +1,290 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* End-to-end leave + rejoin round-trip at the [MarmotManager] layer.
*
* One level above [com.vitorpamplona.quartz.marmot.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd]
* in quartz, which only exercises the MLS engine. This one drives the full
* commons-layer pipeline:
*
* - NIP-59 gift wrap / unwrap of the Welcome (kind:1059 kind:13 kind:444),
* - ChaCha20-Poly1305 outer layer + MLS PrivateMessage decryption of group
* events (kind:445) via `MarmotManager.ingest`,
* - persisted inner-event log via [MarmotMessageStore],
* - subscription bookkeeping in [com.vitorpamplona.quartz.marmot.MarmotSubscriptionManager],
* - KeyPackage bundle lifecycle via [KeyPackageBundleStore] +
* [com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager].
*
* Scenarios covered in one flow:
* 1. Alice creates the group with a [MarmotGroupData] extension
* (required so the receiver's `processWelcome` can derive the
* nostrGroupId admin list left empty so MIP-03 gates don't fire).
* 2. Bob publishes a KeyPackage, Alice `addMember`s it, Bob `ingest`s the
* resulting gift wrap and joins.
* 3. Round-trip: Alice sends a kind:9 via `buildTextMessage`, Bob ingests
* the resulting kind:445, the plaintext lands in his [MarmotMessageStore].
* 4. `bob.leaveGroup(...)` wipes Bob's MLS state, persisted messages and
* subscription for that group.
* 5. Alice evicts Bob's leaf with an admin-kick Remove commit. (Standalone-
* proposal ingestion at [com.vitorpamplona.quartz.marmot.MarmotInboundProcessor]
* is a separate TODO this test pins down the cleanup + rejoin story
* around it.)
* 6. Bob publishes a fresh KeyPackage, Alice `addMember`s it, Bob
* `ingest`s the second gift wrap and rejoins the same nostrGroupId.
* 7. Round-trip after rejoin proves the shared tree / keying material
* are in sync and that `MarmotManager.ingest` correctly routes a
* kind:445 authored at the new epoch.
*/
class MarmotManagerLeaveRejoinTest {
private val nostrGroupId = "a".repeat(64)
private val relay =
RelayUrlNormalizer.normalizeOrNull("wss://example.invalid/")
?: error("test relay must normalize")
@Test
fun testLeaveAndRejoin_CommonsLayer() {
runBlocking {
val alice = buildManager()
val bob = buildManager()
// Step 1: Alice creates the group with a NostrGroupData extension.
val metadata =
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "leave-rejoin-commons",
relays = listOf(relay.url),
)
alice.manager.createGroup(nostrGroupId, metadata)
assertTrue(alice.manager.isMember(nostrGroupId))
// Step 2: Bob publishes a KP, Alice adds him, Bob ingests the wrap.
val bobKp1 = bob.manager.generateKeyPackageEvent(listOf(relay))
val (commitEvent1, delivery1) =
alice.manager.addMember(nostrGroupId, bobKp1, listOf(relay))
assertNotNull(delivery1, "Alice's first add must produce a gift-wrapped Welcome")
val firstJoin = bob.manager.ingest(delivery1.giftWrapEvent)
assertIs<MarmotIngestResult.JoinedGroup>(
firstJoin,
"Bob must join via the gift-wrapped Welcome",
)
assertEquals(nostrGroupId, firstJoin.nostrGroupId)
assertTrue(bob.manager.isMember(nostrGroupId))
assertTrue(
bob.manager.subscriptionManager.isSubscribed(nostrGroupId),
"Joining must register a subscription for the group",
)
// The commit that added Bob also arrives at Alice (echo) — ingest
// is idempotent: her own pipeline marks the id processed before
// publish, so a replay routes to Ignored.
assertIs<MarmotIngestResult.Ignored>(alice.manager.ingest(commitEvent1.signedEvent))
// Step 3: Alice sends a kind:9 inner event. Bob ingests the kind:445.
val helloBefore = "hello before leave"
val helloBundle = alice.manager.buildTextMessage(nostrGroupId, helloBefore)
val helloResult = bob.manager.ingest(helloBundle.outbound.signedEvent)
val helloMessage =
assertIs<MarmotIngestResult.Message>(helloResult, "Bob should decrypt Alice's kind:9")
assertEquals(nostrGroupId, helloMessage.inner.groupId)
val bobMessages1 = bob.manager.loadStoredMessages(nostrGroupId)
assertTrue(
bobMessages1.any { it.contains(helloBefore) },
"Bob's persisted log should contain Alice's pre-leave message; was $bobMessages1",
)
// Step 4: Bob leaves. SelfRemove bytes are returned; local state wiped.
val leaveOutbound = bob.manager.leaveGroup(nostrGroupId)
assertTrue(leaveOutbound.signedEvent.content.isNotEmpty())
assertFalse(
bob.manager.isMember(nostrGroupId),
"Bob must no longer hold the MLS group after leaveGroup",
)
assertNull(
bob.mlsStore.load(nostrGroupId),
"Bob's MLS state must be deleted from the store",
)
assertEquals(
emptyList(),
bob.manager.loadStoredMessages(nostrGroupId),
"Bob's persisted inner-event log must be wiped on leave",
)
assertFalse(
bob.manager.subscriptionManager.isSubscribed(nostrGroupId),
"Bob's subscription for the group must be cleared on leave",
)
// Step 5: Alice evicts Bob's leaf (admin-kick stands in for the
// still-unimplemented standalone-proposal ingestion pipeline).
// Bob's leaf was assigned during addMember; look it up against
// Alice's MLS group view to avoid hard-coding a leaf index.
val bobLeaf =
alice.manager.groupManager
.getGroup(nostrGroupId)
?.memberIdentityHex(1)
?.let { 1 }
?: error("Bob must still occupy leaf 1 on Alice's side")
alice.manager.removeMember(nostrGroupId, bobLeaf)
assertEquals(1, alice.manager.memberCount(nostrGroupId))
// Step 6: Bob rejoins with a fresh KP under the SAME nostrGroupId.
val bobKp2 = bob.manager.generateKeyPackageEvent(listOf(relay))
val (_, delivery2) =
alice.manager.addMember(nostrGroupId, bobKp2, listOf(relay))
assertNotNull(delivery2, "Alice's re-add must produce a second gift-wrapped Welcome")
val secondJoin = bob.manager.ingest(delivery2.giftWrapEvent)
assertIs<MarmotIngestResult.JoinedGroup>(
secondJoin,
"Bob must rejoin via the second gift wrap",
)
assertEquals(nostrGroupId, secondJoin.nostrGroupId)
assertTrue(bob.manager.isMember(nostrGroupId))
assertTrue(
bob.manager.subscriptionManager.isSubscribed(nostrGroupId),
"Rejoining must re-subscribe Bob to the group",
)
assertNotNull(
bob.mlsStore.load(nostrGroupId),
"Bob's store must hold fresh MLS state for the rejoined group",
)
// Step 7: Round-trip after rejoin — proves the new epoch works
// end-to-end through commons, not just that the Welcome deserialized.
val helloAfter = "hello after rejoin"
val afterBundle = alice.manager.buildTextMessage(nostrGroupId, helloAfter)
val afterResult = bob.manager.ingest(afterBundle.outbound.signedEvent)
val afterMessage =
assertIs<MarmotIngestResult.Message>(
afterResult,
"Bob should decrypt Alice's post-rejoin kind:9",
)
assertEquals(nostrGroupId, afterMessage.inner.groupId)
val bobMessages2 = bob.manager.loadStoredMessages(nostrGroupId)
assertTrue(
bobMessages2.any { it.contains(helloAfter) },
"Bob's fresh log should contain the post-rejoin message; was $bobMessages2",
)
assertFalse(
bobMessages2.any { it.contains(helloBefore) },
"Pre-leave messages MUST NOT survive the rejoin — forward secrecy sanity",
)
}
}
// --- Test fixtures -----------------------------------------------------
private data class Fixture(
val manager: MarmotManager,
val mlsStore: InMemoryMlsGroupStateStore,
val kpStore: InMemoryKeyPackageBundleStore,
val messageStore: InMemoryMarmotMessageStore,
)
private fun buildManager(): Fixture {
val signer = NostrSignerInternal(KeyPair())
val mls = InMemoryMlsGroupStateStore()
val kp = InMemoryKeyPackageBundleStore()
val msg = InMemoryMarmotMessageStore()
return Fixture(MarmotManager(signer, mls, msg, kp), mls, kp, msg)
}
}
private class InMemoryMlsGroupStateStore : MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
override suspend fun save(
nostrGroupId: String,
state: ByteArray,
) {
states[nostrGroupId] = state
}
override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId]
override suspend fun delete(nostrGroupId: String) {
states.remove(nostrGroupId)
retained.remove(nostrGroupId)
}
override suspend fun listGroups(): List<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
private class InMemoryKeyPackageBundleStore : KeyPackageBundleStore {
private var snapshot: ByteArray? = null
override suspend fun save(snapshot: ByteArray) {
this.snapshot = snapshot
}
override suspend fun load(): ByteArray? = snapshot
override suspend fun delete() {
snapshot = null
}
}
private class InMemoryMarmotMessageStore : MarmotMessageStore {
private val messages = mutableMapOf<String, MutableList<String>>()
override suspend fun appendMessage(
nostrGroupId: String,
innerEventJson: String,
) {
messages.getOrPut(nostrGroupId) { mutableListOf() }.add(innerEventJson)
}
override suspend fun loadMessages(nostrGroupId: String): List<String> = messages[nostrGroupId]?.toList() ?: emptyList()
override suspend fun delete(nostrGroupId: String) {
messages.remove(nostrGroupId)
}
}
@@ -191,6 +191,61 @@ class MarmotManager(
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Build a kind:7 reaction inner event targeting another inner event in
* the same group. Like [buildTextMessage], the inner event is an
* unsigned rumor (MIP-03). The reaction uses NIP-25 conventions: content
* is the emoji / `+` / `-`; e-tag + p-tag + k-tag reference the target.
*/
suspend fun buildReactionMessage(
nostrGroupId: HexKey,
targetEvent: Event,
reaction: String,
persistOwn: Boolean = true,
): TextMessageBundle {
val template =
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(
reaction,
com.vitorpamplona.quartz.nip01Core.hints
.EventHintBundle(targetEvent),
)
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip25Reactions.ReactionEvent>(
signer.pubKey,
template,
)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Build a kind:5 deletion inner event targeting one or more prior inner
* events in the same group. Unsigned rumor (MIP-03); e-tag + k-tag for
* each target per NIP-09.
*/
suspend fun buildDeletionMessage(
nostrGroupId: HexKey,
targetEvents: List<Event>,
persistOwn: Boolean = true,
): TextMessageBundle {
require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" }
val template =
com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
.build(targetEvents)
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip09Deletions.DeletionEvent>(
signer.pubKey,
template,
)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Add a member to a group by consuming their published [KeyPackageEvent].
*
@@ -295,6 +350,36 @@ class MarmotManager(
return nostrGroupId
}
/**
* Nuke all local Marmot state every MLS group, every retained epoch
* secret, every persisted KeyPackage bundle, and every relay
* subscription. Does NOT publish any leave/SelfRemove commits: the
* reset path is specifically for recovering from corrupted or
* unrecoverable local state where graceful teardown may be impossible.
*
* The caller is responsible for wiping any higher-level in-memory
* structures (e.g. `MarmotGroupList`) and for re-publishing a fresh
* KeyPackage once the reset completes, if the account is still active.
*/
suspend fun resetAllState() {
Log.w("MarmotManager") { "resetAllState(): wiping all Marmot local state for ${signer.pubKey.take(8)}" }
try {
groupManager.clearAllState()
} catch (e: Exception) {
Log.w("MarmotManager", "resetAllState(): groupManager.clearAllState failed: ${e.message}")
}
try {
keyPackageRotationManager.clearAllState()
} catch (e: Exception) {
Log.w("MarmotManager", "resetAllState(): keyPackageRotationManager.clearAllState failed: ${e.message}")
}
try {
subscriptionManager.clear()
} catch (e: Exception) {
Log.w("MarmotManager", "resetAllState(): subscriptionManager.clear failed: ${e.message}")
}
}
/**
* Leave a group.
* Returns proposal bytes to publish (as a GroupEvent).
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.MarmotFilters
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
@@ -60,8 +60,16 @@ object KeyPackageFetcher {
}
/**
* One-shot fetch of a user's KeyPackage across [relays], returning the first
* matching event or `null` if none of the relays yielded one before timeout.
* Drain every kind:443 KeyPackage event for [targetPubKey] across [relays]
* and return the most recently published one (highest `created_at`), or
* `null` if nothing arrived before the timeout.
*
* Returning the newest is important after a KeyPackage rotation: MIP-00
* does not use addressable replacement for kind:443, so a relay may still
* hold the prior KP alongside the new one. `fetchFirst` would race the
* relays and pick whichever replied first, which would frequently be the
* older event. Draining to EOSE and selecting by `created_at` matches
* MDK/whitenoise semantics and keeps freshly-rotated bundles reachable.
*/
suspend fun fetchKeyPackage(
client: INostrClient,
@@ -71,8 +79,10 @@ object KeyPackageFetcher {
): KeyPackageEvent? {
if (relays.isEmpty()) return null
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
return event as? KeyPackageEvent
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
// fetchAll returns events sorted by created_at DESC, so the first
// KeyPackageEvent is the most recent one any relay had.
return events.firstNotNullOfOrNull { it as? KeyPackageEvent }
}
/**
@@ -157,6 +157,26 @@ class KeyPackageRotationManager(
}
}
/**
* Wipe all in-memory KeyPackage bundles plus the persisted snapshot.
* Intended for user-initiated Marmot resets the caller is responsible
* for re-publishing KeyPackages afterwards (e.g. via
* `Account.ensureMarmotKeyPackagePublished`).
*/
suspend fun clearAllState() =
mutex.withLock {
activeBundles.clear()
pendingRotations.clear()
eventIdToSlot.clear()
namedSlotDTags.clear()
val store = store ?: return@withLock
try {
store.delete()
} catch (e: Exception) {
Log.w("KeyPackageRotationManager", "clearAllState(): failed to delete snapshot: ${e.message}")
}
}
private data class Snapshot(
val bundles: Map<String, KeyPackageBundle>,
val pending: Set<String>,
@@ -520,6 +520,32 @@ class MlsGroupManager(
store.delete(nostrGroupId)
}
/**
* Wipe all MLS group state, both in-memory and on-disk. Intended as a
* user-initiated "nuclear" reset for the Marmot subsystem does NOT
* publish any SelfRemove/leave events, because the reset path is meant
* to recover from unusable local state where graceful leave may not
* even be possible.
*
* The union of in-memory group IDs and [MlsGroupStateStore.listGroups]
* is used so any orphaned on-disk state (e.g. a prior restore that
* failed to decode into memory) is also removed.
*/
suspend fun clearAllState() =
mutex.withLock {
val ids = (groups.keys + store.listGroups().toSet()).toList()
Log.d(TAG) { "clearAllState(): wiping ${ids.size} group(s): $ids" }
for (id in ids) {
try {
removeGroupStateUnlocked(id)
} catch (e: Exception) {
Log.w(TAG) { "clearAllState(): failed to wipe $id: ${e.message}" }
}
}
groups.clear()
retainedEpochs.clear()
}
// --- Key Export ---
/**
@@ -20,15 +20,19 @@
*/
package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* In-memory implementation of [MlsGroupStateStore] for testing.
@@ -220,4 +224,150 @@ class MlsGroupManagerTest {
assertTrue(manager2.isMember(groupId2))
}
}
/**
* End-to-end amethyst amethyst leave + rejoin round-trip at the
* [MlsGroupManager] layer. Covers the gaps left by
* [MlsGroupLifecycleTest.testReaddAfterRemove_RejoinerCanEncryptAndDecrypt]
* (which operates a layer below on raw [MlsGroup] instances):
*
* 1. Bob's [leaveGroup] must actually wipe his persisted state
* `isMember` flips to false and the store no longer holds his group.
* 2. Alice (admin) applying a Remove commit for the same leaf keeps
* her own group consistent afterwards.
* 3. Bob can rejoin the SAME `nostrGroupId` with a fresh KeyPackage;
* the leave-side state cleanup must not leave any stale residue
* that would corrupt the new Welcome.
* 4. Post-rejoin, bidirectional MLS encryption works in both
* directions so the new epoch's tree, keying material and
* exporter secrets are all in lockstep across both managers.
*/
@Test
fun testLeaveAndRejoin_SameGroupIdEndToEnd() {
runBlocking {
val aliceStore = InMemoryGroupStateStore()
val alice = MlsGroupManager(aliceStore)
val bobStore = InMemoryGroupStateStore()
val bob = MlsGroupManager(bobStore)
// Seed with a NostrGroupData extension so Bob's processWelcome
// can derive the nostrGroupId from the Welcome's GroupContext.
// adminPubkeys is intentionally empty — the admin-depletion
// guard (MlsGroup.enforceNoAdminDepletion) and MIP-03
// committer-authority check both short-circuit when no admins
// are configured, so Alice can drive all commits and Bob can
// SelfRemove without tripping the admin gate.
val groupData =
MarmotGroupData(
nostrGroupId = groupId,
name = "leave-rejoin",
)
alice.createGroup(
groupId,
"alice".encodeToByteArray(),
initialExtensions = listOf(groupData.toExtension()),
)
assertTrue(alice.isMember(groupId))
// Bob publishes his first KeyPackage via a throwaway scratch
// group (mirrors how a standalone KP is generated in
// production before a Welcome has ever been seen).
val bobBundle1 =
MlsGroup
.create("bob".encodeToByteArray())
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes())
val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome")
bob.processWelcome(firstWelcome, bobBundle1, hintNostrGroupId = groupId)
assertTrue(bob.isMember(groupId))
assertEquals(2, alice.getGroup(groupId)!!.memberCount)
assertEquals(2, bob.getGroup(groupId)!!.memberCount)
// Baseline round-trip both directions.
val hello = "hello before leave".encodeToByteArray()
assertContentEquals(
hello,
bob.getGroup(groupId)!!.decrypt(alice.getGroup(groupId)!!.encrypt(hello)).content,
)
val pong = "pong before leave".encodeToByteArray()
assertContentEquals(
pong,
alice.getGroup(groupId)!!.decrypt(bob.getGroup(groupId)!!.encrypt(pong)).content,
)
// --- Leave: Bob's unilateral local teardown. -----------------
// MlsGroupManager.leaveGroup returns the SelfRemove proposal
// bytes (Alice would normally ingest these via the inbound
// pipeline and fold them into a commit). It ALSO deletes Bob's
// group from his store — the return contract is "your state
// is gone now, the bytes are your farewell". Verify both.
val (selfRemoveBytes, _) = bob.leaveGroup(groupId)
assertTrue(selfRemoveBytes.isNotEmpty(), "SelfRemove bytes must be returned")
assertFalse(bob.isMember(groupId), "Bob must no longer hold the group")
assertNull(bobStore.load(groupId), "Bob's disk state for the group must be gone")
assertEquals(
emptyList(),
bobStore.loadRetainedEpochs(groupId),
"Bob's retained-epoch secrets must be wiped too",
)
// --- Admin commit that actually evicts Bob's leaf. ----------
// The standalone-proposal ingestion pipeline is a separate
// concern (MarmotInboundProcessor.processPublicMessage still
// returns "Standalone proposals not yet supported" for the
// PROPOSAL content type). Here we verify the equivalent
// admin-kick flow: Alice removes Bob's leaf directly, which
// is what an admin auto-commit would do after picking up the
// proposal.
val bobLeafIndex = bob.getGroup(groupId)?.leafIndex ?: 1
alice.removeMember(groupId, bobLeafIndex)
assertEquals(
1,
alice.getGroup(groupId)!!.memberCount,
"Alice must be solo after evicting Bob",
)
// --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. -
val bobBundle2 =
MlsGroup
.create("bob".encodeToByteArray())
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes())
val secondWelcome =
secondAdd.welcomeBytes ?: fail("Alice's re-add must produce a Welcome")
bob.processWelcome(secondWelcome, bobBundle2, hintNostrGroupId = groupId)
assertTrue(
bob.isMember(groupId),
"Bob must be a member again under the same nostrGroupId",
)
assertEquals(2, bob.getGroup(groupId)!!.memberCount)
assertNotNull(
bobStore.load(groupId),
"Bob's store must hold fresh state for the same nostrGroupId",
)
// Epoch must be aligned.
assertEquals(
alice.getGroup(groupId)!!.epoch,
bob.getGroup(groupId)!!.epoch,
"Rejoiner must sit at Alice's current epoch",
)
// Bidirectional round-trip after rejoin — proves the shared
// tree, keying material and exporter secrets are all back in
// sync, not just that the Welcome deserialized cleanly.
val afterHello = "hello after rejoin".encodeToByteArray()
assertContentEquals(
afterHello,
bob.getGroup(groupId)!!.decrypt(alice.getGroup(groupId)!!.encrypt(afterHello)).content,
)
val afterPong = "pong after rejoin".encodeToByteArray()
assertContentEquals(
afterPong,
alice.getGroup(groupId)!!.decrypt(bob.getGroup(groupId)!!.encrypt(afterPong)).content,
)
}
}
}