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:
@@ -219,14 +219,26 @@ class Context(
|
||||
* - kind:445 group events per active group → feed into inbound processor
|
||||
*
|
||||
* Incrementally advances the `since` cursors in [state] so the next run
|
||||
* only asks relays for newer events.
|
||||
* only asks relays for newer events. Two wrinkles:
|
||||
*
|
||||
* 1. NIP-59 gift wraps are published with a random-past `created_at`
|
||||
* (see [com.vitorpamplona.quartz.utils.TimeUtils.randomWithTwoDays])
|
||||
* so a newly-published wrap can trivially have `created_at` earlier
|
||||
* than the last cursor we saw. To avoid silently dropping such wraps
|
||||
* we always subtract a 2-day lookback window from the gift-wrap
|
||||
* `since`, and dedup is handled inside [MarmotInboundProcessor].
|
||||
* 2. We only advance the on-disk cursor when events actually arrive.
|
||||
* Snapping an empty sync up to "now" on the first invocation would
|
||||
* make every later `since` query skip any past-dated wrap or 445.
|
||||
*/
|
||||
suspend fun syncIncoming(timeoutMs: Long = 8_000) {
|
||||
val inbox = inboxRelays().ifEmpty { anyRelays() }
|
||||
val gwSince = state.giftWrapSince
|
||||
val gwFilterSince =
|
||||
gwSince?.let { (it - GIFT_WRAP_LOOKBACK_SECS).coerceAtLeast(0L) }
|
||||
val gwFilter =
|
||||
if (gwSince != null) {
|
||||
MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince)
|
||||
if (gwFilterSince != null) {
|
||||
MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwFilterSince)
|
||||
} else {
|
||||
MarmotFilters.giftWrapsForUser(identity.pubKeyHex)
|
||||
}
|
||||
@@ -255,34 +267,89 @@ class Context(
|
||||
if (filterMap.isEmpty()) return
|
||||
|
||||
val events = drain(filterMap, timeoutMs)
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
|
||||
var maxGwSeen = gwSince ?: 0L
|
||||
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
|
||||
var sawGiftWrap = false
|
||||
val sawGroupEvent = mutableSetOf<HexKey>()
|
||||
|
||||
for ((relay, event) in events) {
|
||||
// All the MLS/NIP-59 decryption + persistence lives in MarmotIngest —
|
||||
// we only care about bookkeeping (since-cursors, logging) here.
|
||||
val result = marmot.ingest(event)
|
||||
System.err.println("[cli] ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}")
|
||||
val detail =
|
||||
when (result) {
|
||||
is com.vitorpamplona.amethyst.commons.marmot.MarmotIngestResult.Failure -> " ${result.message}"
|
||||
else -> ""
|
||||
}
|
||||
System.err.println("[cli] ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail")
|
||||
|
||||
when (event.kind) {
|
||||
GiftWrapEvent.KIND -> {
|
||||
sawGiftWrap = true
|
||||
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
|
||||
}
|
||||
|
||||
GroupEvent.KIND -> {
|
||||
val gid = (event as? GroupEvent)?.groupId() ?: continue
|
||||
sawGroupEvent.add(gid)
|
||||
val prev = maxGroupSeen[gid] ?: 0L
|
||||
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now
|
||||
for ((gid, seen) in maxGroupSeen) {
|
||||
state.groupSince[gid] = if (seen > 0) seen else now
|
||||
if (sawGiftWrap && maxGwSeen > 0) {
|
||||
state.giftWrapSince = maxGwSeen
|
||||
}
|
||||
for (gid in sawGroupEvent) {
|
||||
val seen = maxGroupSeen[gid] ?: continue
|
||||
if (seen > 0) state.groupSince[gid] = seen
|
||||
}
|
||||
|
||||
// If any welcome we processed consumed a KeyPackage, MIP-00 requires
|
||||
// us to immediately publish a replacement (a KP can only be used for
|
||||
// ONE welcome; leaving the old one on relays lets a second sender
|
||||
// invite us with a bundle we no longer have private keys for). The
|
||||
// Amethyst UI handles this via its own rotation scheduler; the CLI
|
||||
// has no scheduler, so we rotate inline right after sync.
|
||||
if (marmot.needsKeyPackageRotation()) {
|
||||
try {
|
||||
val kpRelays = keyPackageRelays().ifEmpty { outboxRelays() }.ifEmpty { anyRelays() }
|
||||
if (kpRelays.isNotEmpty()) {
|
||||
val rotated = marmot.rotateConsumedKeyPackages(kpRelays.toList())
|
||||
for (event in rotated) {
|
||||
publish(event, kpRelays)
|
||||
System.err.println("[cli] rotated KeyPackage → ${event.id.take(8)} on ${kpRelays.size} relay(s)")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
System.err.println("[cli] key-package rotation failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a group identifier given on the CLI to the nostr_group_id that
|
||||
* amy's [MarmotManager] indexes on.
|
||||
*
|
||||
* amy internally keys everything off MIP-01's `nostr_group_id`. whitenoise
|
||||
* (and every other mdk consumer) keys off the MLS `GroupContext.groupId` —
|
||||
* a separate 32-byte random value stamped at group creation. Cross-client
|
||||
* scripts therefore wind up juggling both ids, and it's very easy to pass
|
||||
* the wrong one to amy. Rather than make every caller translate, we accept
|
||||
* either format and resolve here:
|
||||
* 1. If the input is an active nostr_group_id, use it unchanged.
|
||||
* 2. Otherwise scan active groups for one whose MLS groupId matches.
|
||||
* 3. Otherwise return the input unchanged (so the caller still gets a
|
||||
* sensible `not_member` response rather than a silent mismatch).
|
||||
*/
|
||||
fun resolveGroupId(input: HexKey): HexKey {
|
||||
if (marmot.isMember(input)) return input
|
||||
val normalized = input.lowercase()
|
||||
return marmot.activeGroupIds().firstOrNull { nostrId ->
|
||||
marmot.mlsGroupIdHex(nostrId)?.lowercase() == normalized
|
||||
} ?: input
|
||||
}
|
||||
|
||||
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
|
||||
@@ -303,6 +370,13 @@ class Context(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Lookback applied to the gift-wrap `since` filter to compensate for
|
||||
* NIP-59's randomised-past `created_at`. 2 days matches
|
||||
* [com.vitorpamplona.quartz.utils.TimeUtils.randomWithTwoDays].
|
||||
*/
|
||||
private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
|
||||
|
||||
/** Build a Context but require an identity to already exist — most commands can't run without one. */
|
||||
fun open(dataDir: DataDir): Context {
|
||||
val identity =
|
||||
|
||||
@@ -108,6 +108,7 @@ object AwaitCommands {
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to match,
|
||||
"mls_group_id" to ctx.marmot.mlsGroupIdHex(match),
|
||||
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
|
||||
"epoch" to ctx.marmot.groupEpoch(match),
|
||||
),
|
||||
@@ -127,7 +128,7 @@ object AwaitCommands {
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||
val gid = rawArgs[0]
|
||||
val gid = ctx.resolveGroupId(rawArgs[0])
|
||||
val target = ctx.requireUserHex(rawArgs[1])
|
||||
if (!ctx.marmot.isMember(gid)) {
|
||||
null
|
||||
@@ -143,7 +144,7 @@ object AwaitCommands {
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||
val gid = rawArgs[0]
|
||||
val gid = ctx.resolveGroupId(rawArgs[0])
|
||||
val target = ctx.requireUserHex(rawArgs[1])
|
||||
if (!ctx.marmot.isMember(gid)) {
|
||||
null
|
||||
@@ -163,13 +164,13 @@ object AwaitCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await rename <gid> --name <name>")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val wantedName = args.requireFlag("name")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
@@ -191,13 +192,13 @@ object AwaitCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await epoch <gid> --min N")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val min = args.longFlag("min", 1)
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
@@ -219,13 +220,13 @@ object AwaitCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await message <gid> --match STRING")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val needle = args.requireFlag("match")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
|
||||
+1
-1
@@ -37,10 +37,10 @@ object GroupAddMemberCommand {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
||||
val gid = rest[0]
|
||||
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)
|
||||
|
||||
|
||||
@@ -40,9 +40,12 @@ object GroupCreateCommand {
|
||||
ctx.prepare()
|
||||
val gid = RandomInstance.bytes(32).toHexKey()
|
||||
|
||||
ctx.marmot.createGroup(gid)
|
||||
|
||||
// Stamp initial metadata via the shared factory so UI + CLI stay byte-identical.
|
||||
// Stamp initial metadata via the shared factory so UI + CLI stay
|
||||
// byte-identical. Bake the MarmotGroupData extension into the
|
||||
// epoch-0 GroupContext directly (see `MarmotManager.createGroup`)
|
||||
// so later invitees receive a pre-populated group from the
|
||||
// welcome and never have to chase an undecryptable bootstrap
|
||||
// commit that predates their membership.
|
||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||
val metadata =
|
||||
MarmotGroupData.bootstrap(
|
||||
@@ -51,19 +54,14 @@ object GroupCreateCommand {
|
||||
outboxRelays = outboxUrls,
|
||||
name = name,
|
||||
)
|
||||
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
|
||||
|
||||
// Group relays == what the metadata carries, which on first commit is our outbox.
|
||||
val targets = ctx.outboxRelays()
|
||||
val ack = ctx.publish(commit.signedEvent, targets)
|
||||
ctx.marmot.createGroup(gid, initialMetadata = metadata)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
|
||||
"name" to name,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"commit_event_id" to commit.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
+30
-2
@@ -30,10 +30,10 @@ object GroupMembershipCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
|
||||
val gid = rest[0]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
val target = ctx.requireUserHex(rest[1])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
@@ -66,18 +66,46 @@ object GroupMembershipCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>")
|
||||
val gid = rest[0]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rest[0])
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
|
||||
// MIP-01/MIP-03: members listed in `admin_pubkeys` MUST NOT issue
|
||||
// a SelfRemove proposal before first publishing a GCE that drops
|
||||
// themselves from the admin list (MlsGroup.selfRemove enforces
|
||||
// this with `check(!isLocalAdmin())`), and that same GCE MUST NOT
|
||||
// leave the group with zero admins (admin depletion). If we're
|
||||
// the only admin, hand admin to another member first.
|
||||
val demoteEventId: String? =
|
||||
ctx.marmot.groupMetadata(gid)?.let { metadata ->
|
||||
if (!metadata.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null
|
||||
|
||||
val newAdmins = metadata.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList()
|
||||
if (newAdmins.isEmpty()) {
|
||||
val heir =
|
||||
ctx.marmot
|
||||
.memberPubkeys(gid)
|
||||
.map { it.pubkey }
|
||||
.firstOrNull { it != ctx.identity.pubKeyHex }
|
||||
?: return@let null // solo group — skip demote, let MLS state cleanup handle it
|
||||
newAdmins.add(heir)
|
||||
}
|
||||
val demoted = metadata.copy(adminPubkeys = newAdmins)
|
||||
val demoteCommit = ctx.marmot.updateGroupMetadata(gid, demoted)
|
||||
ctx.publish(demoteCommit.signedEvent, targets)
|
||||
demoteCommit.signedEvent.id
|
||||
}
|
||||
|
||||
val outbound = ctx.marmot.leaveGroup(gid)
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"self_demote_event_id" to demoteEventId,
|
||||
"proposal_event_id" to outbound.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
|
||||
+2
-1
@@ -66,12 +66,13 @@ object GroupMetadataCommands {
|
||||
|
||||
private suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
gid: HexKey,
|
||||
rawGid: HexKey,
|
||||
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
|
||||
): Int {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = ctx.resolveGroupId(rawGid)
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||
|
||||
@@ -56,10 +56,10 @@ object GroupReadCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>")
|
||||
val gid = rest[0]
|
||||
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 meta = ctx.marmot.groupMetadata(gid)
|
||||
@@ -70,6 +70,7 @@ object GroupReadCommands {
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
|
||||
"name" to (meta?.name ?: ""),
|
||||
"description" to (meta?.description ?: ""),
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
@@ -90,10 +91,10 @@ object GroupReadCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>")
|
||||
val gid = rest[0]
|
||||
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 members =
|
||||
@@ -112,10 +113,10 @@ object GroupReadCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>")
|
||||
val gid = rest[0]
|
||||
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 m = ctx.marmot.groupMetadata(gid)
|
||||
|
||||
@@ -45,11 +45,11 @@ object MessageCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>")
|
||||
val gid = rest[0]
|
||||
val text = rest[1]
|
||||
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)
|
||||
|
||||
@@ -77,12 +77,12 @@ object MessageCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val limit = args.intFlag("limit", Int.MAX_VALUE)
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user