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
@@ -2263,6 +2263,14 @@ class Account(
/** /**
* Leave a Marmot MLS group. * Leave a Marmot MLS group.
* Publishes the SelfRemove proposal and removes local state. * Publishes the SelfRemove proposal and removes local state.
*
* MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions
* commit dropping themselves from `admin_pubkeys` before issuing a
* SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws
* `IllegalStateException("Admin must self-demote via GroupContextExtensions
* before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and
* SelfRemove proposal both go to the same group relays, demote first so
* peers apply it before they see the SelfRemove.
*/ */
suspend fun leaveMarmotGroup( suspend fun leaveMarmotGroup(
nostrGroupId: HexKey, nostrGroupId: HexKey,
@@ -2271,6 +2279,27 @@ class Account(
val manager = marmotManager ?: return val manager = marmotManager ?: return
if (!isWriteable()) return if (!isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
if (remaining.isEmpty()) {
val heir =
manager
.memberPubkeys(nostrGroupId)
.map { it.pubkey }
.firstOrNull { it != signer.pubKey }
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
client.publish(demoteCommit.signedEvent, groupRelays)
}
}
val outbound = manager.leaveGroup(nostrGroupId) val outbound = manager.leaveGroup(nostrGroupId)
client.publish(outbound.signedEvent, groupRelays) client.publish(outbound.signedEvent, groupRelays)
} }
@@ -219,14 +219,26 @@ class Context(
* - kind:445 group events per active group → feed into inbound processor * - kind:445 group events per active group → feed into inbound processor
* *
* Incrementally advances the `since` cursors in [state] so the next run * 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) { suspend fun syncIncoming(timeoutMs: Long = 8_000) {
val inbox = inboxRelays().ifEmpty { anyRelays() } val inbox = inboxRelays().ifEmpty { anyRelays() }
val gwSince = state.giftWrapSince val gwSince = state.giftWrapSince
val gwFilterSince =
gwSince?.let { (it - GIFT_WRAP_LOOKBACK_SECS).coerceAtLeast(0L) }
val gwFilter = val gwFilter =
if (gwSince != null) { if (gwFilterSince != null) {
MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince) MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwFilterSince)
} else { } else {
MarmotFilters.giftWrapsForUser(identity.pubKeyHex) MarmotFilters.giftWrapsForUser(identity.pubKeyHex)
} }
@@ -255,34 +267,89 @@ class Context(
if (filterMap.isEmpty()) return if (filterMap.isEmpty()) return
val events = drain(filterMap, timeoutMs) val events = drain(filterMap, timeoutMs)
val now = System.currentTimeMillis() / 1000
var maxGwSeen = gwSince ?: 0L var maxGwSeen = gwSince ?: 0L
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap() val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
var sawGiftWrap = false
val sawGroupEvent = mutableSetOf<HexKey>()
for ((relay, event) in events) { for ((relay, event) in events) {
// All the MLS/NIP-59 decryption + persistence lives in MarmotIngest — // All the MLS/NIP-59 decryption + persistence lives in MarmotIngest —
// we only care about bookkeeping (since-cursors, logging) here. // we only care about bookkeeping (since-cursors, logging) here.
val result = marmot.ingest(event) 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) { when (event.kind) {
GiftWrapEvent.KIND -> { GiftWrapEvent.KIND -> {
sawGiftWrap = true
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
} }
GroupEvent.KIND -> { GroupEvent.KIND -> {
val gid = (event as? GroupEvent)?.groupId() ?: continue val gid = (event as? GroupEvent)?.groupId() ?: continue
sawGroupEvent.add(gid)
val prev = maxGroupSeen[gid] ?: 0L val prev = maxGroupSeen[gid] ?: 0L
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
} }
} }
} }
state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now if (sawGiftWrap && maxGwSeen > 0) {
for ((gid, seen) in maxGroupSeen) { state.giftWrapSince = maxGwSeen
state.groupSince[gid] = if (seen > 0) seen else now
} }
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> { fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
@@ -303,6 +370,13 @@ class Context(
} }
companion object { 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. */ /** Build a Context but require an identity to already exist — most commands can't run without one. */
fun open(dataDir: DataDir): Context { fun open(dataDir: DataDir): Context {
val identity = val identity =
@@ -108,6 +108,7 @@ object AwaitCommands {
Json.writeLine( Json.writeLine(
mapOf( mapOf(
"group_id" to match, "group_id" to match,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(match),
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""), "name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
"epoch" to ctx.marmot.groupEpoch(match), "epoch" to ctx.marmot.groupEpoch(match),
), ),
@@ -127,7 +128,7 @@ object AwaitCommands {
rest: Array<String>, rest: Array<String>,
): Int = ): Int =
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs -> 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]) val target = ctx.requireUserHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) { if (!ctx.marmot.isMember(gid)) {
null null
@@ -143,7 +144,7 @@ object AwaitCommands {
rest: Array<String>, rest: Array<String>,
): Int = ): Int =
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs -> 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]) val target = ctx.requireUserHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) { if (!ctx.marmot.isMember(gid)) {
null null
@@ -163,13 +164,13 @@ object AwaitCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await rename <gid> --name <name>") 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 args = Args(rest.drop(1).toTypedArray())
val wantedName = args.requireFlag("name") val wantedName = args.requireFlag("name")
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val deadline = System.currentTimeMillis() + timeoutSecs * 1000 val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000) ctx.syncIncoming(timeoutMs = 3_000)
@@ -191,13 +192,13 @@ object AwaitCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await epoch <gid> --min N") 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 args = Args(rest.drop(1).toTypedArray())
val min = args.longFlag("min", 1) val min = args.longFlag("min", 1)
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val deadline = System.currentTimeMillis() + timeoutSecs * 1000 val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000) ctx.syncIncoming(timeoutMs = 3_000)
@@ -219,13 +220,13 @@ object AwaitCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await message <gid> --match STRING") 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 args = Args(rest.drop(1).toTypedArray())
val needle = args.requireFlag("match") val needle = args.requireFlag("match")
val timeoutSecs = args.longFlag("timeout", 30) val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val deadline = System.currentTimeMillis() + timeoutSecs * 1000 val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000) ctx.syncIncoming(timeoutMs = 3_000)
@@ -37,10 +37,10 @@ object GroupAddMemberCommand {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]") if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
val gid = rest[0]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
@@ -40,9 +40,12 @@ object GroupCreateCommand {
ctx.prepare() ctx.prepare()
val gid = RandomInstance.bytes(32).toHexKey() val gid = RandomInstance.bytes(32).toHexKey()
ctx.marmot.createGroup(gid) // Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
// Stamp initial metadata via the shared factory so UI + CLI stay byte-identical. // 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 outboxUrls = ctx.outboxRelays().map { it.url }
val metadata = val metadata =
MarmotGroupData.bootstrap( MarmotGroupData.bootstrap(
@@ -51,19 +54,14 @@ object GroupCreateCommand {
outboxRelays = outboxUrls, outboxRelays = outboxUrls,
name = name, name = name,
) )
val commit = ctx.marmot.updateGroupMetadata(gid, metadata) ctx.marmot.createGroup(gid, initialMetadata = 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)
Json.writeLine( Json.writeLine(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
"name" to name, "name" to name,
"epoch" to ctx.marmot.groupEpoch(gid), "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 return 0
@@ -30,10 +30,10 @@ object GroupMembershipCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>") if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
val gid = rest[0]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val target = ctx.requireUserHex(rest[1]) val target = ctx.requireUserHex(rest[1])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
@@ -66,18 +66,46 @@ object GroupMembershipCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>") if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>")
val gid = rest[0]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } 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 outbound = ctx.marmot.leaveGroup(gid)
val ack = ctx.publish(outbound.signedEvent, targets) val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine( Json.writeLine(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"self_demote_event_id" to demoteEventId,
"proposal_event_id" to outbound.signedEvent.id, "proposal_event_id" to outbound.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url }, "published_to" to ack.filterValues { it }.keys.map { it.url },
), ),
@@ -66,12 +66,13 @@ object GroupMetadataCommands {
private suspend fun edit( private suspend fun edit(
dataDir: DataDir, dataDir: DataDir,
gid: HexKey, rawGid: HexKey,
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData, mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
): Int { ): Int {
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rawGid)
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val outboxUrls = ctx.outboxRelays().map { it.url } val outboxUrls = ctx.outboxRelays().map { it.url }
@@ -56,10 +56,10 @@ object GroupReadCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>") if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val meta = ctx.marmot.groupMetadata(gid) val meta = ctx.marmot.groupMetadata(gid)
@@ -70,6 +70,7 @@ object GroupReadCommands {
Json.writeLine( Json.writeLine(
mapOf( mapOf(
"group_id" to gid, "group_id" to gid,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
"name" to (meta?.name ?: ""), "name" to (meta?.name ?: ""),
"description" to (meta?.description ?: ""), "description" to (meta?.description ?: ""),
"epoch" to ctx.marmot.groupEpoch(gid), "epoch" to ctx.marmot.groupEpoch(gid),
@@ -90,10 +91,10 @@ object GroupReadCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>") if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val members = val members =
@@ -112,10 +113,10 @@ object GroupReadCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>") if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val m = ctx.marmot.groupMetadata(gid) val m = ctx.marmot.groupMetadata(gid)
@@ -45,11 +45,11 @@ object MessageCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>") if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>")
val gid = rest[0]
val text = rest[1] val text = rest[1]
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
@@ -77,12 +77,12 @@ object MessageCommands {
rest: Array<String>, rest: Array<String>,
): Int { ): Int {
if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>") if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray()) val args = Args(rest.drop(1).toTypedArray())
val limit = args.intFlag("limit", Int.MAX_VALUE) val limit = args.intFlag("limit", Int.MAX_VALUE)
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming() ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent 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 = private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
try { try {
val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored // NIP-59 wraps contain TWO encryption layers:
if (!MarmotInboundProcessor.isWelcomeEvent(inner) || inner !is WelcomeEvent) { // 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 return MarmotIngestResult.Ignored
} }
when (val result = processWelcome(inner, inner.nostrGroupId())) { when (val result = processWelcome(rumor, rumor.nostrGroupId())) {
is WelcomeResult.Joined -> { is WelcomeResult.Joined -> {
MarmotIngestResult.JoinedGroup( MarmotIngestResult.JoinedGroup(
nostrGroupId = result.nostrGroupId, nostrGroupId = result.nostrGroupId,
@@ -262,11 +262,24 @@ class MarmotManager(
/** /**
* Create a new MLS group. * 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)}" } Log.d("MarmotManager") { "createGroup($nostrGroupId): by ${signer.pubKey.take(8)}" }
val identity = signer.pubKey.hexToByteArray() 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) subscriptionManager.subscribeGroup(nostrGroupId)
Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" } Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" }
return nostrGroupId return nostrGroupId
@@ -277,15 +290,18 @@ class MarmotManager(
* Returns proposal bytes to publish (as a GroupEvent). * Returns proposal bytes to publish (as a GroupEvent).
*/ */
suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent { suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent {
// Build the outbound event BEFORE deleting group state (needs exporter secret) // leaveGroup() returns the framed standalone SelfRemove proposal
val group = // (PublicMessage{Proposal}) plus the pre-commit exporter key for
groupManager.getGroup(nostrGroupId) // outer encryption. Runs BEFORE we tear down the subscriptions so
?: throw IllegalStateException("Not a member of group $nostrGroupId") // the pre-commit exporter is still derivable.
val proposalBytes = group.selfRemove() val (framedBytes, exporterKey) = groupManager.leaveGroup(nostrGroupId)
val outboundEvent = outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes) val outboundEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = framedBytes,
exporterKey = exporterKey,
)
// Now clean up group state
groupManager.removeGroupState(nostrGroupId)
subscriptionManager.unsubscribeGroup(nostrGroupId) subscriptionManager.unsubscribeGroup(nostrGroupId)
try { try {
messageStore?.delete(nostrGroupId) messageStore?.delete(nostrGroupId)
@@ -346,13 +362,24 @@ class MarmotManager(
/** /**
* Update group metadata (name, description, etc.) via a GroupContextExtensions proposal. * Update group metadata (name, description, etc.) via a GroupContextExtensions proposal.
* Creates a GCE proposal, commits it, and returns the commit event to publish. * 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( suspend fun updateGroupMetadata(
nostrGroupId: HexKey, nostrGroupId: HexKey,
metadata: MarmotGroupData, metadata: MarmotGroupData,
): OutboundGroupEvent { ): OutboundGroupEvent {
val commitResult = val group =
groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension())) 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 = val commitEvent =
outboundProcessor.buildCommitEvent( outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId, nostrGroupId = nostrGroupId,
@@ -480,6 +507,18 @@ class MarmotManager(
*/ */
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch 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 * Resolve the MLS leaf index for a member by Nostr pubkey, or null if that
* pubkey isn't currently in the group. * pubkey isn't currently in the group.
@@ -511,7 +511,26 @@ class MarmotInboundProcessor(
when (mlsMessage.wireFormat) { when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> { WireFormat.PRIVATE_MESSAGE -> {
// For private commits, MLS decrypt handles epoch advancement // Sniff the PrivateMessage epoch without consuming any
// ratchet state. Past-epoch echoes and future-epoch
// arrivals must not advance the secret tree — otherwise
// the real handshake / application message gets rejected
// when it finally arrives.
val privPeek = PrivateMessage.decodeTls(TlsReader(mlsMessage.payload))
val currentEpoch = groupManager.getGroup(groupId)?.epoch
when {
currentEpoch != null && privPeek.epoch < currentEpoch -> {
GroupEventResult.Duplicate(groupId)
}
currentEpoch != null && privPeek.epoch > currentEpoch -> {
GroupEventResult.Error(
groupId,
"PrivateMessage epoch ${privPeek.epoch} is ahead of local epoch $currentEpoch; ignoring",
)
}
else -> {
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes()) val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
if (decrypted.contentType == ContentType.COMMIT) { if (decrypted.contentType == ContentType.COMMIT) {
val group = groupManager.getGroup(groupId) val group = groupManager.getGroup(groupId)
@@ -523,6 +542,8 @@ class MarmotInboundProcessor(
) )
} }
} }
}
}
WireFormat.PUBLIC_MESSAGE -> { WireFormat.PUBLIC_MESSAGE -> {
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload)) val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
@@ -556,14 +577,29 @@ class MarmotInboundProcessor(
} }
else -> { else -> {
// RFC 9420 §6.2 — reject PublicMessage commits
// whose membership_tag doesn't match what the
// current epoch's membership_key would produce.
// Without this an outsider with the outer
// exporter secret could inject arbitrary commit
// bytes and advance the group past them.
val group = groupManager.getGroup(groupId)
if (group != null && !group.verifyPublicMessageCommitMembershipTag(pubMsg)) {
GroupEventResult.Error(
groupId,
"Invalid membership_tag on PublicMessage commit",
)
} else {
groupManager.processCommit( groupManager.processCommit(
nostrGroupId = groupId, nostrGroupId = groupId,
commitBytes = pubMsg.content, commitBytes = pubMsg.content,
senderLeafIndex = pubMsg.sender.leafIndex, senderLeafIndex = pubMsg.sender.leafIndex,
confirmationTag = tag, confirmationTag = tag,
signature = pubMsg.signature,
) )
val group = groupManager.getGroup(groupId) val post = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0) GroupEventResult.CommitProcessed(groupId, post?.epoch ?: 0)
}
} }
} }
} }
@@ -191,7 +191,22 @@ data class MarmotGroupData(
fun toExtension(): Extension = Extension(EXTENSION_ID_INT, encodeTls()) fun toExtension(): Extension = Extension(EXTENSION_ID_INT, encodeTls())
companion object { companion object {
const val CURRENT_VERSION = 3 /**
* Version we emit for freshly created groups and fresh GCE commits.
*
* Held at 2 (not 3) because the Rust mdk-core MLS engine used by
* whitenoise-rs (the only other shipping Marmot client today) is
* stricter than MIP-01's "ignore trailing bytes for forward
* compatibility" rule — it rejects v3 payloads with
* `ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`,
* which means our v3 welcomes/commits never get applied and every
* cross-client group flow breaks. We still PARSE v3 happily via
* [decodeTls] (any peer that sends us a v3 group will round-trip),
* but we don't create them until mdk-core catches up.
*
* Bump back to 3 once mdk publishes the forward-compat fix.
*/
const val CURRENT_VERSION = 2
/** Versions this implementation understands. v0 is reserved/invalid per MIP-01. */ /** Versions this implementation understands. v0 is reserved/invalid per MIP-01. */
val SUPPORTED_VERSIONS: Set<Int> = setOf(1, 2, 3) val SUPPORTED_VERSIONS: Set<Int> = setOf(1, 2, 3)
@@ -97,6 +97,18 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance
* val key = group.exporterSecret("marmot", "group-event", 32) * val key = group.exporterSecret("marmot", "group-event", 32)
* ``` * ```
*/ */
private fun constantTimeEquals(
a: ByteArray,
b: ByteArray,
): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
class MlsGroup private constructor( class MlsGroup private constructor(
private var groupContext: GroupContext, private var groupContext: GroupContext,
private val tree: RatchetTree, private val tree: RatchetTree,
@@ -411,6 +423,15 @@ class MlsGroup private constructor(
val preCommitExporterSecret = val preCommitExporterSecret =
exporterSecret("marmot", "group-event".encodeToByteArray(), 32) exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
// Snapshot the pre-proposal extensions. GroupContextExtensions proposals
// mutate `groupContext.extensions` the moment they're applied, but
// openmls signs the commit's FramedContentTBS over the UNMUTATED
// context (the one in `public_group` before `diff` applies proposals).
// Without this snapshot, a GCE commit on the quartz side uses the new
// extensions for TBS + membership_tag, and openmls rejects it with
// `ValidationError(InvalidMembershipTag)`.
val preCommitExtensions = groupContext.extensions
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) } val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
// Check if we need an UpdatePath. RFC 9420 §12.4.1: the path value // Check if we need an UpdatePath. RFC 9420 §12.4.1: the path value
@@ -445,55 +466,48 @@ class MlsGroup private constructor(
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH) val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val pathSecrets = tree.derivePathSecrets(myLeafIndex, leafSecret) val pathSecrets = tree.derivePathSecrets(myLeafIndex, leafSecret)
// Build UpdatePath with HPKE-encrypted path secrets for each copath node // RFC 9420 §12.4.1: newly-added leaves (from Add proposals in THIS commit)
val updatePath = // MUST be excluded from the copath resolution — they join via the Welcome
// at epoch N+1 and don't need the path secret. Keeping them in the list
// shifts every other resolution index by one, so strict receivers
// (openmls/mdk) pick the wrong ciphertext and fail with
// UpdatePathError(UnableToDecrypt).
val newLeafIndices = addedMembers.map { it.first }.toSet()
// Build the UpdatePath in three stages so the HPKE info used for path-
// secret encryption matches what openmls/mdk compute:
// 1. derive path-secret keypairs and stage the committer's new leaf
// 2. apply the path locally, patch parent_hashes, swap in the leaf
// 3. compute the post-mutation tree_hash + bump epoch so
// `serialized_group_context` has the new tree_hash, the new epoch,
// and the old confirmed_transcript_hash — then HPKE-encrypt each
// copath resolution under that intermediate context
// Without this ordering the committer uses the PRE-commit context and
// every strict-validating member derives a different AEAD key, turning
// the decryption into `UpdatePathError(UnableToDecrypt)`.
val updatePath: UpdatePath? =
if (needsPath && pathSecrets.isNotEmpty()) { if (needsPath && pathSecrets.isNotEmpty()) {
val copath = BinaryTree.copath(myLeafIndex, tree.leafCount) val copath = BinaryTree.copath(myLeafIndex, tree.leafCount)
val pathNodes =
pathSecrets.zip(copath).map { (pathKey, copathNode) ->
val resolution = tree.resolution(copathNode)
val encryptedSecrets =
resolution.mapNotNull { resNode ->
val node = tree.getNode(resNode) ?: return@mapNotNull null
val recipientPub =
when (node) {
is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Leaf -> {
node.leafNode.encryptionKey
}
is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent -> {
node.parentNode.encryptionKey
}
}
MlsCryptoProvider.encryptWithLabel(
recipientPub,
"UpdatePathNode",
groupContext.toTlsBytes(),
pathKey.pathSecret,
)
}
UpdatePathNode(pathKey.publicKey, encryptedSecrets)
}
// Capture sibling tree hashes BEFORE applying the UpdatePath — // Capture sibling tree hashes BEFORE applying the UpdatePath —
// parent_hash computation (RFC 9420 §7.9.2) uses the // parent_hash computation (RFC 9420 §7.9.2) uses the
// ORIGINAL sibling-subtree tree hashes. // ORIGINAL sibling-subtree tree hashes.
val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(myLeafIndex) val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(myLeafIndex)
// Apply the UpdatePath to our own tree first so the parent // Stage path-keys into the tree so subsequent parent_hash /
// nodes carry the new encryption keys. Their parent_hash // tree_hash computations reflect the new keys. We'll fill in
// fields are filled in next. // the HPKE-encrypted secrets once we know the post-commit
tree.applyUpdatePath(myLeafIndex, pathNodes) // context bytes.
val stagedPathNodes =
pathSecrets.zip(copath).map { (pathKey, _) ->
UpdatePathNode(pathKey.publicKey, emptyList())
}
tree.applyUpdatePath(myLeafIndex, stagedPathNodes)
// Compute parent_hash for every direct-path parent node and // Compute parent_hash for every direct-path parent node and
// for the committer's leaf (RFC 9420 §7.9.2). Without this // for the committer's leaf (RFC 9420 §7.9.2).
// chain, spec-strict peers (ts-mls, OpenMLS) reject the
// Welcome with "Unable to verify parent hash".
val (parentNodeHashes, leafParentHash) = val (parentNodeHashes, leafParentHash) =
computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes) computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes)
// Patch each parent node with its computed parent_hash so
// subsequent treeHash / serialization uses the final values.
val directPath = BinaryTree.directPath(myLeafIndex, tree.leafCount) val directPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
for (nodeIdx in directPath) { for (nodeIdx in directPath) {
val existing = tree.getNode(nodeIdx) val existing = tree.getNode(nodeIdx)
@@ -528,38 +542,117 @@ class MlsGroup private constructor(
leafIndex = myLeafIndex, leafIndex = myLeafIndex,
parentHash = leafParentHash, parentHash = leafParentHash,
) )
encryptionPrivateKey = newEncKp.privateKey encryptionPrivateKey = newEncKp.privateKey
tree.setLeaf(myLeafIndex, newLeafNode) tree.setLeaf(myLeafIndex, newLeafNode)
// Path-encryption context (RFC 9420 §7.6 / openmls `compute_path`):
// serialize the GroupContext AFTER applying the tree mutations
// and bumping the epoch, but BEFORE the confirmed_transcript_hash
// gets folded in. We don't commit this copy to the field yet —
// the transcript-hash update below needs the current value.
val pathEncContextBytes =
groupContext
.copy(
epoch = groupContext.epoch + 1,
treeHash = tree.treeHash(),
).toTlsBytes()
val pathNodes =
stagedPathNodes.zip(copath).zip(pathSecrets) { (staged, copathNode), pathKey ->
val resolution =
tree.resolution(copathNode).filterNot { resNode ->
BinaryTree.isLeaf(resNode) &&
BinaryTree.nodeToLeaf(resNode) in newLeafIndices
}
val encryptedSecrets =
resolution.mapNotNull { resNode ->
val node = tree.getNode(resNode) ?: return@mapNotNull null
val recipientPub =
when (node) {
is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Leaf -> {
node.leafNode.encryptionKey
}
is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent -> {
node.parentNode.encryptionKey
}
}
MlsCryptoProvider.encryptWithLabel(
recipientPub,
"UpdatePathNode",
pathEncContextBytes,
pathKey.pathSecret,
)
}
UpdatePathNode(staged.encryptionKey, encryptedSecrets)
}
UpdatePath(newLeafNode, pathNodes) UpdatePath(newLeafNode, pathNodes)
} else { } else {
null null
} }
val commit = Commit(proposalOrRefs, updatePath) val commit = Commit(proposalOrRefs, updatePath)
val commitBytes = commit.toTlsBytes()
// Advance epoch // Advance epoch.
// RFC 9420 §9.2: commit_secret is the path_secret for the "virtual" node
// one step past the root — i.e. `DeriveSecret(path_secret_at_root, "path")`,
// NOT the path_secret at the root itself. Openmls/mdk derives exactly this
// value; quartz was using the root's own path_secret, which is the
// encryption-key seed rather than the key-schedule contribution. That
// one-step gap silently diverged the two sides' epoch_secret and made
// every cross-impl commit fail `ConfirmationTagMismatch`.
val commitSecret = val commitSecret =
if (pathSecrets.isNotEmpty()) { if (pathSecrets.isNotEmpty()) {
pathSecrets.last().pathSecret MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
} else { } else {
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
} }
// Update transcript hashes (RFC 9420 Section 8.2)
val confirmedTranscriptHashInput = buildConfirmedTranscriptHashInput(commit, myLeafIndex)
val confirmedInput = TlsWriter()
confirmedInput.putBytes(interimTranscriptHash)
confirmedInput.putBytes(confirmedTranscriptHashInput)
val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray())
val newTreeHash = tree.treeHash() val newTreeHash = tree.treeHash()
val oldEpoch = groupContext.epoch val oldEpoch = groupContext.epoch
val preCommitGroupId = groupContext.groupId val preCommitGroupId = groupContext.groupId
val committerLeafIndex = myLeafIndex val committerLeafIndex = myLeafIndex
val newEpoch = oldEpoch + 1 val newEpoch = oldEpoch + 1
// Capture pre-commit values needed to sign and membership-MAC the
// outbound PublicMessage (RFC 9420 §6.1 / §6.2). The signature and
// membership_tag are computed under the epoch in which the commit
// is sent — the one we're about to leave. Receivers (openmls/mdk)
// strict-verify both, so we must use the leaf signing key that's
// still in the pre-commit tree and the membership_key derived from
// the pre-commit epoch secrets. Extensions need explicit rewind to
// pre-proposal state for GroupContextExtensions commits.
val preCommitContextBytes =
groupContext.copy(extensions = preCommitExtensions).toTlsBytes()
val preCommitMembershipKey = epochSecrets.membershipKey
val preCommitSigningKey = signingPrivateKey
// Sign FramedContentTBS BEFORE folding the commit into the transcript
// hash. RFC 9420 §8.2: ConfirmedTranscriptHashInput contains the real
// signature — using `ByteArray(0)` here produces a confirmed_transcript_hash
// that the receiver cannot reproduce, so strict peers reject the commit
// with `ConfirmationTagMismatch`.
val commitTbsBytes =
buildCommitFramedContentTbs(
groupId = preCommitGroupId,
epoch = oldEpoch,
senderLeafIndex = committerLeafIndex,
commitBytes = commitBytes,
groupContextBytes = preCommitContextBytes,
)
val commitSignature =
MlsCryptoProvider.signWithLabel(preCommitSigningKey, "FramedContentTBS", commitTbsBytes)
// Update transcript hashes (RFC 9420 §8.2) with the real signature.
val confirmedTranscriptHashInput =
buildConfirmedTranscriptHashInput(commit, myLeafIndex, commitSignature)
val confirmedInput = TlsWriter()
confirmedInput.putBytes(interimTranscriptHash)
confirmedInput.putBytes(confirmedTranscriptHashInput)
val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray())
groupContext = groupContext =
groupContext.copy( groupContext.copy(
epoch = newEpoch, epoch = newEpoch,
@@ -599,7 +692,6 @@ class MlsGroup private constructor(
pendingProposals.clear() pendingProposals.clear()
sentKeys.clear() sentKeys.clear()
val commitBytes = commit.toTlsBytes()
val framedCommitBytes = val framedCommitBytes =
framePublicMessageCommit( framePublicMessageCommit(
groupId = preCommitGroupId, groupId = preCommitGroupId,
@@ -607,6 +699,9 @@ class MlsGroup private constructor(
senderLeafIndex = committerLeafIndex, senderLeafIndex = committerLeafIndex,
commitBytes = commitBytes, commitBytes = commitBytes,
confirmationTag = confirmationTag, confirmationTag = confirmationTag,
signature = commitSignature,
membershipKey = preCommitMembershipKey,
tbsBytes = commitTbsBytes,
) )
return CommitResult( return CommitResult(
commitBytes = commitBytes, commitBytes = commitBytes,
@@ -791,15 +886,27 @@ class MlsGroup private constructor(
"Sender leaf is blank at index $senderLeafIndex (not a group member)" "Sender leaf is blank at index $senderLeafIndex (not a group member)"
} }
// Get the key/nonce for this sender+generation // Get the key/nonce for this sender+generation from the correct ratchet.
// If we sent this message ourselves, use the cached key to avoid ratchet conflict // PrivateMessage Application content uses the application ratchet (RFC 9420
// §6.3.2); PrivateMessage Commit / Proposal handshakes use a separate
// per-sender handshake ratchet. openmls / mdk default to AlwaysCiphertext
// outgoing, so every B→A commit quartz receives lands here with
// content_type == COMMIT.
val kng = val kng =
if (senderLeafIndex == myLeafIndex && sentKeys.containsKey(generation)) { if (senderLeafIndex == myLeafIndex && sentKeys.containsKey(generation)) {
sentKeys.remove(generation)!! sentKeys.remove(generation)!!
} else { } else {
when (privMsg.contentType) {
ContentType.APPLICATION -> {
secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation) secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation)
} }
ContentType.COMMIT, ContentType.PROPOSAL -> {
secretTree.handshakeKeyNonceForGeneration(senderLeafIndex, generation)
}
}
}
// Apply reuse_guard XOR to nonce (RFC 9420 §6.3.1) // Apply reuse_guard XOR to nonce (RFC 9420 §6.3.1)
val guardedNonce = kng.nonce.copyOf() val guardedNonce = kng.nonce.copyOf()
for (i in 0 until REUSE_GUARD_LENGTH) { for (i in 0 until REUSE_GUARD_LENGTH) {
@@ -810,16 +917,15 @@ class MlsGroup private constructor(
val contentAad = buildPrivateContentAAD(privMsg.groupId, privMsg.epoch, privMsg.contentType, privMsg.authenticatedData) val contentAad = buildPrivateContentAAD(privMsg.groupId, privMsg.epoch, privMsg.contentType, privMsg.authenticatedData)
val pmcPlaintext = MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad, privMsg.ciphertext) val pmcPlaintext = MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad, privMsg.ciphertext)
// Parse PrivateMessageContent (RFC 9420 §6.3.1) and verify the // Parse PrivateMessageContent (RFC 9420 §6.3.1). The layout depends on
// sender's FramedContentTBS signature before returning bytes. // content_type — application payloads carry `opaque application_data<V>`
require(privMsg.contentType == ContentType.APPLICATION) { // whereas commit / proposal payloads carry the struct directly (no
// Commit/Proposal-via-PrivateMessage decoding is not implemented. // outer length prefix).
"decrypt() only supports application-content PrivateMessages"
}
val pmcReader = TlsReader(pmcPlaintext) val pmcReader = TlsReader(pmcPlaintext)
when (privMsg.contentType) {
ContentType.APPLICATION -> {
val applicationData = pmcReader.readOpaqueVarInt() val applicationData = pmcReader.readOpaqueVarInt()
val signature = pmcReader.readOpaqueVarInt() val signature = pmcReader.readOpaqueVarInt()
// Remaining bytes are padding; all must be zero per §6.3.1.
while (pmcReader.hasRemaining) { while (pmcReader.hasRemaining) {
require(pmcReader.readBytes(1)[0] == 0.toByte()) { require(pmcReader.readBytes(1)[0] == 0.toByte()) {
"PrivateMessageContent padding must be zero" "PrivateMessageContent padding must be zero"
@@ -854,6 +960,45 @@ class MlsGroup private constructor(
) )
} }
ContentType.COMMIT -> {
// PrivateMessageContent for a Commit: the Commit struct
// (no length prefix) followed by signature<V> and
// confirmation_tag<V>, then zero padding. The caller drives
// processCommit with (commitBytes, signature, confirmationTag)
// — all available here once we re-serialize the parsed Commit
// back to bytes (openmls does the same round-trip).
val commit = Commit.decodeTls(pmcReader)
val commitWriter = TlsWriter()
commit.encodeTls(commitWriter)
val commitBytes = commitWriter.toByteArray()
val signature = pmcReader.readOpaqueVarInt()
val confirmationTag = pmcReader.readOpaqueVarInt()
while (pmcReader.hasRemaining) {
require(pmcReader.readBytes(1)[0] == 0.toByte()) {
"PrivateMessageContent padding must be zero"
}
}
// Apply the commit inline — after this returns, the caller
// only needs to know the epoch advanced. The signature + wire
// format ride into the transcript-hash computation inside
// processCommit (RFC 9420 §8.2 requires the real wire format
// for ConfirmedTranscriptHashInput).
processCommit(commitBytes, senderLeafIndex, confirmationTag, signature, WireFormat.PRIVATE_MESSAGE)
return DecryptedMessage(
senderLeafIndex = senderLeafIndex,
contentType = privMsg.contentType,
content = commitBytes,
epoch = privMsg.epoch,
)
}
ContentType.PROPOSAL -> {
throw IllegalStateException("Standalone PrivateMessage proposals not yet supported")
}
}
}
/** /**
* Build FramedContentTBS for an application-content PrivateMessage * Build FramedContentTBS for an application-content PrivateMessage
* (RFC 9420 §6.1). The signature over this is what lives in the * (RFC 9420 §6.1). The signature over this is what lives in the
@@ -897,11 +1042,59 @@ class MlsGroup private constructor(
* @param commitBytes TLS-serialized Commit * @param commitBytes TLS-serialized Commit
* @param senderLeafIndex the sender's leaf index in the ratchet tree * @param senderLeafIndex the sender's leaf index in the ratchet tree
* @param confirmationTag optional confirmation tag from the PublicMessage for verification * @param confirmationTag optional confirmation tag from the PublicMessage for verification
* @param signature FramedContentAuthData.signature from the PublicMessage wrapper;
* required to reproduce the ConfirmedTranscriptHashInput (RFC 9420 §8.2) exactly.
* Callers that don't have access to the PublicMessage envelope (test fixtures)
* may pass `ByteArray(0)` that path only interoperates with senders that
* also pass an empty signature.
*/ */
fun processCommit( fun processCommit(
commitBytes: ByteArray, commitBytes: ByteArray,
senderLeafIndex: Int, senderLeafIndex: Int,
confirmationTag: ByteArray, confirmationTag: ByteArray,
signature: ByteArray = ByteArray(0),
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
) {
// Snapshot all mutable state BEFORE applying any proposals or
// UpdatePath mutations. If any step throws (bad signature, parent
// hash mismatch, decrypt failure, confirmation-tag divergence), we
// restore the snapshot so callers observe the group in the same
// state as if processCommit had never run. Without this rollback
// a partial mutation leaves the tree one epoch ahead of
// `groupContext.epoch` and every subsequent decrypt fails with
// `Message epoch X doesn't match current epoch Y`.
val treeSnapshot = tree.snapshot()
val ctxSnapshot = groupContext
val secretsSnapshot = epochSecrets
val initSnapshot = initSecret
val secretTreeSnapshot = secretTree
val interimSnapshot = interimTranscriptHash
val pendingSnapshot = pendingProposals.toList()
val sentKeysSnapshot = sentKeys.toMap()
try {
processCommitInner(commitBytes, senderLeafIndex, confirmationTag, signature, wireFormat)
} catch (t: Throwable) {
tree.restoreFrom(treeSnapshot)
groupContext = ctxSnapshot
epochSecrets = secretsSnapshot
initSecret = initSnapshot
secretTree = secretTreeSnapshot
interimTranscriptHash = interimSnapshot
pendingProposals.clear()
pendingProposals.addAll(pendingSnapshot)
sentKeys.clear()
sentKeys.putAll(sentKeysSnapshot)
throw t
}
}
private fun processCommitInner(
commitBytes: ByteArray,
senderLeafIndex: Int,
confirmationTag: ByteArray,
signature: ByteArray,
wireFormat: WireFormat,
) { ) {
val commit = Commit.decodeTls(TlsReader(commitBytes)) val commit = Commit.decodeTls(TlsReader(commitBytes))
@@ -925,13 +1118,49 @@ class MlsGroup private constructor(
} }
} }
// Apply proposals (resolve references from pending pool) // Verify the FramedContentTBS signature (RFC 9420 §6.1) against
// Collect all resolved proposals for key schedule computation // the sender's pre-commit leaf signature_key. Any non-external
// commit MUST carry a non-empty signature from the sender's leaf
// — without this check anyone with the outer exporter key (in a
// compromised relay scenario) could forge commits.
if (!isExternalCommit) {
require(signature.isNotEmpty()) {
"FramedContentTBS signature missing on commit from leaf $senderLeafIndex"
}
val senderLeaf =
requireNotNull(tree.getLeaf(senderLeafIndex)) {
"Sender leaf is blank at index $senderLeafIndex"
}
val tbs =
buildCommitFramedContentTbs(
groupId = groupContext.groupId,
epoch = groupContext.epoch,
senderLeafIndex = senderLeafIndex,
commitBytes = commitBytes,
groupContextBytes = groupContext.toTlsBytes(),
wireFormat = wireFormat,
)
require(MlsCryptoProvider.verifyWithLabel(senderLeaf.signatureKey, "FramedContentTBS", tbs, signature)) {
"Invalid FramedContentTBS signature on commit from leaf $senderLeafIndex"
}
}
// Apply proposals (resolve references from pending pool).
// Matches the committer's order: apply non-Add proposals first, then Adds,
// so leaves freed by Remove are available for Add reuse (RFC 9420 §12.4.2).
// Also track the post-Add leaf indices so the UpdatePath resolution filter
// can exclude them (mirrors the encryption-side exclusion).
val resolvedProposals = mutableListOf<Proposal>() val resolvedProposals = mutableListOf<Proposal>()
val inlineAdds = mutableListOf<Proposal.Add>()
val referenceAddSenders = mutableListOf<Pair<Proposal.Add, Int>>()
for (proposalOrRef in commit.proposals) { for (proposalOrRef in commit.proposals) {
when (proposalOrRef) { when (proposalOrRef) {
is ProposalOrRef.Inline -> { is ProposalOrRef.Inline -> {
if (proposalOrRef.proposal is Proposal.Add) {
inlineAdds.add(proposalOrRef.proposal)
} else {
applyProposal(proposalOrRef.proposal, senderLeafIndex) applyProposal(proposalOrRef.proposal, senderLeafIndex)
}
resolvedProposals.add(proposalOrRef.proposal) resolvedProposals.add(proposalOrRef.proposal)
} }
@@ -947,11 +1176,22 @@ class MlsGroup private constructor(
requireNotNull(resolved) { requireNotNull(resolved) {
"Commit references unknown proposal (ref not found in pending proposals)" "Commit references unknown proposal (ref not found in pending proposals)"
} }
if (resolved.proposal is Proposal.Add) {
referenceAddSenders.add(resolved.proposal to resolved.senderLeafIndex)
} else {
applyProposal(resolved.proposal, resolved.senderLeafIndex) applyProposal(resolved.proposal, resolved.senderLeafIndex)
}
resolvedProposals.add(resolved.proposal) resolvedProposals.add(resolved.proposal)
} }
} }
} }
val newLeavesInCommit = mutableSetOf<Int>()
for (add in inlineAdds) {
newLeavesInCommit.add(applyProposalAdd(add))
}
for ((add, _) in referenceAddSenders) {
newLeavesInCommit.add(applyProposalAdd(add))
}
// Process UpdatePath // Process UpdatePath
var commitSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) var commitSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
@@ -961,6 +1201,17 @@ class MlsGroup private constructor(
require(verifyLeafNodeSignature(updatePath.leafNode, groupId, senderLeafIndex)) { require(verifyLeafNodeSignature(updatePath.leafNode, groupId, senderLeafIndex)) {
"Invalid LeafNode signature in UpdatePath" "Invalid LeafNode signature in UpdatePath"
} }
// RFC 9420 §8.4: LeafNode lifetime bounds must be current.
// An expired leaf is a revoked key — accepting it here would
// let a peer replay old UpdatePath material past the window
// the signer authorized.
val lifetime = updatePath.leafNode.lifetime
if (lifetime != null) {
val now = TimeUtils.now()
require(now >= lifetime.notBefore && now <= lifetime.notAfter) {
"LeafNode lifetime expired or not yet valid in UpdatePath"
}
}
// For external commits the sender's leaf is not yet in the tree. // For external commits the sender's leaf is not yet in the tree.
// Grow the tree with a blank slot so directPath / sibling-hash // Grow the tree with a blank slot so directPath / sibling-hash
@@ -1004,12 +1255,26 @@ class MlsGroup private constructor(
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount) val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
val myPath = BinaryTree.directPath(myLeafIndex, tree.leafCount) val myPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
// Path-decryption context (RFC 9420 §7.6): matches what the
// committer used to encrypt — post-tree-mutation tree_hash and
// bumped epoch, but pre-commit confirmed_transcript_hash.
val pathDecContextBytes =
groupContext
.copy(
epoch = groupContext.epoch + 1,
treeHash = tree.treeHash(),
).toTlsBytes()
// Find the common ancestor // Find the common ancestor
val commonAncestorIdx = directPath.indexOfFirst { it in myPath } val commonAncestorIdx = directPath.indexOfFirst { it in myPath }
if (commonAncestorIdx >= 0 && commonAncestorIdx < updatePath.nodes.size) { if (commonAncestorIdx >= 0 && commonAncestorIdx < updatePath.nodes.size) {
val pathNode = updatePath.nodes[commonAncestorIdx] val pathNode = updatePath.nodes[commonAncestorIdx]
val copathNodeIdx = BinaryTree.copath(senderLeafIndex, tree.leafCount)[commonAncestorIdx] val copathNodeIdx = BinaryTree.copath(senderLeafIndex, tree.leafCount)[commonAncestorIdx]
val resolution = tree.resolution(copathNodeIdx) val resolution =
tree.resolution(copathNodeIdx).filterNot { resNode ->
BinaryTree.isLeaf(resNode) &&
BinaryTree.nodeToLeaf(resNode) in newLeavesInCommit
}
// Find which encrypted secret corresponds to our position // Find which encrypted secret corresponds to our position
val myNodeIdx = BinaryTree.leafToNode(myLeafIndex) val myNodeIdx = BinaryTree.leafToNode(myLeafIndex)
@@ -1021,28 +1286,30 @@ class MlsGroup private constructor(
MlsCryptoProvider.decryptWithLabel( MlsCryptoProvider.decryptWithLabel(
encryptionPrivateKey, encryptionPrivateKey,
"UpdatePathNode", "UpdatePathNode",
groupContext.toTlsBytes(), pathDecContextBytes,
ct.kemOutput, ct.kemOutput,
ct.ciphertext, ct.ciphertext,
) )
// Derive remaining path secrets from common ancestor up to root. // Derive remaining path secrets from common ancestor up to root,
// pathSecret is the secret AT commonAncestorIdx, so we derive // then one more step to reach the `commit_secret` (RFC 9420 §9.2:
// (directPath.size - commonAncestorIdx - 1) more steps to reach root. // commit_secret = DeriveSecret(root_path_secret, "path")). Openmls
val remainingSteps = directPath.size - commonAncestorIdx - 1 // advances one step past the root; quartz was stopping at the root
// and diverging — that's what caused `ConfirmationTagMismatch` on
// every cross-impl commit.
val stepsToRoot = directPath.size - commonAncestorIdx - 1
var currentSecret = pathSecret var currentSecret = pathSecret
repeat(remainingSteps) { repeat(stepsToRoot) {
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path") currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
} }
commitSecret = currentSecret commitSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
} }
} }
} }
// Update transcript hashes (RFC 9420 Section 8.2) // Update transcript hashes (RFC 9420 Section 8.2)
// ConfirmedTranscriptHashInput = wire_format || FramedContent || signature // ConfirmedTranscriptHashInput = wire_format || FramedContent || signature
// Simplified: use commit TLS bytes as the transcript input val confirmedTranscriptHashInput = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, signature, wireFormat)
val confirmedTranscriptHashInput = buildConfirmedTranscriptHashInput(commit, senderLeafIndex)
val confirmedInput = TlsWriter() val confirmedInput = TlsWriter()
confirmedInput.putBytes(interimTranscriptHash) confirmedInput.putBytes(interimTranscriptHash)
confirmedInput.putBytes(confirmedTranscriptHashInput) confirmedInput.putBytes(confirmedTranscriptHashInput)
@@ -1077,18 +1344,18 @@ class MlsGroup private constructor(
initSecret = epochSecrets.initSecret initSecret = epochSecrets.initSecret
secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount) secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
// Verify confirmation tag (RFC 9420 Section 6.1). In real message // Verify confirmation tag (RFC 9420 Section 6.1). Every commit on
// flows the tag is carried on the wrapping PublicMessage and must be // the wire MUST carry a confirmation_tag that matches what the
// verified. Callers that process a raw commit payload and have // receiver derives from the post-commit confirmation_key — this
// verified the tag externally (or for test harnesses that work with // is the last line of defense against a committer with the
// `Commit.toTlsBytes()` directly) pass `ByteArray(0)`; in that mode // correct signing key but a forged tree state.
// we skip the comparison. Any non-empty tag is still checked.
val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash) val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
if (confirmationTag.isNotEmpty()) { require(confirmationTag.isNotEmpty()) {
"Confirmation tag missing on commit from leaf $senderLeafIndex"
}
require(constantTimeEquals(confirmationTag, expectedTag)) { require(constantTimeEquals(confirmationTag, expectedTag)) {
"Confirmation tag verification failed" "Confirmation tag verification failed"
} }
}
// Update interim_transcript_hash for next epoch (reuse verified expectedTag) // Update interim_transcript_hash for next epoch (reuse verified expectedTag)
val interimInput = TlsWriter() val interimInput = TlsWriter()
@@ -1219,7 +1486,9 @@ class MlsGroup private constructor(
private fun buildConfirmedTranscriptHashInput( private fun buildConfirmedTranscriptHashInput(
commit: Commit, commit: Commit,
senderLeafIndex: Int, senderLeafIndex: Int,
): ByteArray = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, groupId, epoch) signature: ByteArray = ByteArray(0),
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
): ByteArray = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, groupId, epoch, signature, wireFormat)
/** /**
* Compute the RFC 9420 §7.9.2 parent_hash chain for a commit we are * Compute the RFC 9420 §7.9.2 parent_hash chain for a commit we are
@@ -1400,6 +1669,33 @@ class MlsGroup private constructor(
return constantTimeEquals(expectedTag, membershipTag) return constantTimeEquals(expectedTag, membershipTag)
} }
/**
* Verify RFC 9420 §6.2 membership_tag on an inbound PublicMessage Commit.
* The tag binds the whole `(TBS || FramedContentAuthData)` payload to
* the sender's epoch if it's missing or wrong, the sender either
* isn't a member or the message was tampered with. Returns false for
* either case; callers should reject the commit before advancing state.
*/
fun verifyPublicMessageCommitMembershipTag(pubMsg: PublicMessage): Boolean {
val membershipTag = pubMsg.membershipTag ?: return false
if (membershipTag.isEmpty()) return false
val confirmationTag = pubMsg.confirmationTag ?: return false
val tbs =
buildCommitFramedContentTbs(
groupId = pubMsg.groupId,
epoch = pubMsg.epoch,
senderLeafIndex = pubMsg.sender.leafIndex,
commitBytes = pubMsg.content,
groupContextBytes = groupContext.toTlsBytes(),
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
val tbmWriter = TlsWriter()
tbmWriter.putBytes(tbs)
tbmWriter.putOpaqueVarInt(pubMsg.signature)
tbmWriter.putOpaqueVarInt(confirmationTag)
return verifyMembershipTag(tbmWriter.toByteArray(), membershipTag)
}
/** /**
* Build PrivateContentAAD (RFC 9420 §6.3.2): * Build PrivateContentAAD (RFC 9420 §6.3.2):
* ``` * ```
@@ -1447,22 +1743,6 @@ class MlsGroup private constructor(
return writer.toByteArray() return writer.toByteArray()
} }
/**
* Constant-time byte array comparison to prevent timing side-channels.
* Returns true only if both arrays have the same length and contents.
*/
private fun constantTimeEquals(
a: ByteArray,
b: ByteArray,
): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
/** /**
* Apply an Add proposal and return the assigned leaf index. * Apply an Add proposal and return the assigned leaf index.
*/ */
@@ -1740,22 +2020,66 @@ class MlsGroup private constructor(
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0002 private const val RATCHET_TREE_EXTENSION_TYPE = 0x0002
/** /**
* Wrap a raw [Commit] (as [commitBytes]) in an MlsMessage(PublicMessage(...)) * Build the FramedContentTBS bytes for a member-sender commit
* envelope so it can be published on the wire (RFC 9420 §6 / §6.2). * (RFC 9420 §6.1). The signature over this value is the
* `FramedContentAuthData.signature` that rides both in the
* on-the-wire PublicMessage/PrivateMessage AND in the
* ConfirmedTranscriptHashInput.
* *
* The receiver uses the sender's leaf index and the confirmation_tag from * The wire_format argument MUST match the envelope actually used;
* the [PublicMessage] header to drive [MlsGroup.processCommit]. The * mixing PUBLIC_MESSAGE here with a PRIVATE_MESSAGE envelope (or
* `signature` and `membership_tag` opaque fields are intentionally empty * vice versa) produces a signature that receivers can't verify
* the current implementation does not verify them on inbound commits, * because they recompute the TBS with the real wire_format byte.
* but the TLS structure must still be present so decoding succeeds.
*/ */
internal fun buildCommitFramedContentTbs(
groupId: ByteArray,
epoch: Long,
senderLeafIndex: Int,
commitBytes: ByteArray,
groupContextBytes: ByteArray,
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
): ByteArray {
val writer = TlsWriter()
writer.putUint16(MlsMessage.MLS_VERSION_10)
writer.putUint16(wireFormat.value)
writer.putOpaqueVarInt(groupId)
writer.putUint64(epoch)
encodeSender(writer, Sender(SenderType.MEMBER, senderLeafIndex))
writer.putOpaqueVarInt(ByteArray(0)) // authenticated_data
writer.putUint8(ContentType.COMMIT.value)
writer.putBytes(commitBytes) // Commit struct — no outer length prefix
writer.putBytes(groupContextBytes) // member sender appends context raw
return writer.toByteArray()
}
internal fun framePublicMessageCommit( internal fun framePublicMessageCommit(
groupId: ByteArray, groupId: ByteArray,
epoch: Long, epoch: Long,
senderLeafIndex: Int, senderLeafIndex: Int,
commitBytes: ByteArray, commitBytes: ByteArray,
confirmationTag: ByteArray, confirmationTag: ByteArray,
signature: ByteArray,
membershipKey: ByteArray,
tbsBytes: ByteArray,
): ByteArray { ): ByteArray {
// AuthenticatedContentTBM = TBS || FramedContentAuthData, where
// FramedContentAuthData = signature<V> || (for commits) confirmation_tag<V>.
// Caller already signed the TBS so we reuse the exact bytes they
// produced — any drift here would desync the membership_tag from
// the signature.
val tbmWriter = TlsWriter()
tbmWriter.putBytes(tbsBytes)
tbmWriter.putOpaqueVarInt(signature)
tbmWriter.putOpaqueVarInt(confirmationTag)
val tbm = tbmWriter.toByteArray()
// membership_tag = MAC(membership_key, TBM) — HMAC-SHA256 for
// ciphersuite 0x0001. Length = 32; openmls's equal_ct logs
// "Incompatible values" when an empty tag is compared to this.
val macInstance = MacInstance("HmacSHA256", membershipKey)
macInstance.update(tbm)
val membershipTag = macInstance.doFinal()
val publicMessage = val publicMessage =
PublicMessage( PublicMessage(
groupId = groupId, groupId = groupId,
@@ -1764,9 +2088,9 @@ class MlsGroup private constructor(
authenticatedData = ByteArray(0), authenticatedData = ByteArray(0),
contentType = ContentType.COMMIT, contentType = ContentType.COMMIT,
content = commitBytes, content = commitBytes,
signature = ByteArray(0), signature = signature,
confirmationTag = confirmationTag, confirmationTag = confirmationTag,
membershipTag = ByteArray(0), membershipTag = membershipTag,
) )
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes() return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes()
} }
@@ -1780,9 +2104,18 @@ class MlsGroup private constructor(
senderLeafIndex: Int, senderLeafIndex: Int,
groupId: ByteArray, groupId: ByteArray,
epoch: Long, epoch: Long,
signature: ByteArray = ByteArray(0),
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
): ByteArray { ): ByteArray {
// RFC 9420 §8.2: ConfirmedTranscriptHashInput carries the wire_format
// of the actual AuthenticatedContent — openmls passes
// `mls_content.wire_format()` through. When B sends a commit as a
// PrivateMessage (mdk default = AlwaysCiphertext outgoing), amy
// MUST recompute the transcript hash with wire_format=2, or the
// resulting confirmed_transcript_hash — and thus the
// confirmation_tag derived from it — silently diverges from B's.
val writer = TlsWriter() val writer = TlsWriter()
writer.putUint16(WireFormat.PUBLIC_MESSAGE.value) writer.putUint16(wireFormat.value)
writer.putOpaqueVarInt(groupId) writer.putOpaqueVarInt(groupId)
writer.putUint64(epoch) writer.putUint64(epoch)
writer.putUint8(1) // SenderType.MEMBER writer.putUint8(1) // SenderType.MEMBER
@@ -1790,7 +2123,7 @@ class MlsGroup private constructor(
writer.putOpaqueVarInt(ByteArray(0)) // authenticated_data writer.putOpaqueVarInt(ByteArray(0)) // authenticated_data
writer.putUint8(ContentType.COMMIT.value) writer.putUint8(ContentType.COMMIT.value)
commit.encodeTls(writer) commit.encodeTls(writer)
writer.putOpaqueVarInt(ByteArray(0)) // signature placeholder writer.putOpaqueVarInt(signature) // FramedContentAuthData.signature
return writer.toByteArray() return writer.toByteArray()
} }
@@ -1929,6 +2262,7 @@ class MlsGroup private constructor(
fun create( fun create(
identity: ByteArray, identity: ByteArray,
signingKey: ByteArray? = null, signingKey: ByteArray? = null,
initialExtensions: List<com.vitorpamplona.quartz.marmot.mls.tree.Extension> = emptyList(),
): MlsGroup { ): MlsGroup {
val sigKp = val sigKp =
signingKey?.let { key -> signingKey?.let { key ->
@@ -1953,13 +2287,18 @@ class MlsGroup private constructor(
tree.setLeaf(0, leafNode) tree.setLeaf(0, leafNode)
val treeHash = tree.treeHash() val treeHash = tree.treeHash()
// Start with required_capabilities + whatever the caller wants to
// bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so
// new peers who join later can see the group name without first
// decrypting a pre-membership bootstrap commit — see MIP-03).
val baseExtensions = listOf(buildMarmotRequiredCapabilitiesExtension())
val groupContext = val groupContext =
GroupContext( GroupContext(
groupId = groupId, groupId = groupId,
epoch = 0, epoch = 0,
treeHash = treeHash, treeHash = treeHash,
confirmedTranscriptHash = ByteArray(0), confirmedTranscriptHash = ByteArray(0),
extensions = listOf(buildMarmotRequiredCapabilitiesExtension()), extensions = baseExtensions + initialExtensions,
) )
// Initial key schedule with zero secrets // Initial key schedule with zero secrets
@@ -2086,6 +2425,15 @@ class MlsGroup private constructor(
"Invalid GroupInfo signature" "Invalid GroupInfo signature"
} }
// RFC 9420 §12.4.3.1: the ratchet_tree extension the joiner
// reconstructs MUST hash to the tree_hash committed in the
// signed GroupContext. Otherwise a compromised signer could
// feed the joiner a tree different from the one encoded in
// the signed context, silently diverging their key schedule.
require(tree.treeHash().contentEquals(groupContext.treeHash)) {
"GroupInfo tree_hash does not match ratchet_tree extension"
}
// Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3) // Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3)
// For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh) // For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh)
val epochSecret = val epochSecret =
@@ -2124,6 +2472,19 @@ class MlsGroup private constructor(
val confirmMac = MacInstance("HmacSHA256", epochSecrets.confirmationKey) val confirmMac = MacInstance("HmacSHA256", epochSecrets.confirmationKey)
confirmMac.update(groupContext.confirmedTranscriptHash) confirmMac.update(groupContext.confirmedTranscriptHash)
val confirmationTag = confirmMac.doFinal() val confirmationTag = confirmMac.doFinal()
// RFC 9420 §12.4.3.1: the confirmation_tag the joiner would
// derive from the joiner_secret-sourced confirmation_key MUST
// match the confirmation_tag embedded in the signed GroupInfo.
// Without this check a tampered GroupInfo could supply a
// mismatched confirmed_transcript_hash that still passes the
// signature-over-(context, extensions, tag, signer) as long
// as the attacker controls the signer — the confirmation_tag
// binds the epoch secrets to the transcript.
require(constantTimeEquals(groupInfo.confirmationTag, confirmationTag)) {
"GroupInfo confirmation_tag does not match joiner-derived confirmation_key"
}
val interimInput = TlsWriter() val interimInput = TlsWriter()
interimInput.putBytes(groupContext.confirmedTranscriptHash) interimInput.putBytes(groupContext.confirmedTranscriptHash)
interimInput.putOpaqueVarInt(confirmationTag) interimInput.putOpaqueVarInt(confirmationTag)
@@ -2177,6 +2538,14 @@ class MlsGroup private constructor(
"Invalid GroupInfo signature in externalJoin" "Invalid GroupInfo signature in externalJoin"
} }
// RFC 9420 §12.4.3.1: enforce that the reconstructed
// ratchet_tree hashes to the signed tree_hash. Without this,
// a malicious signer could serve an externalJoin consumer
// a tree that diverges from the signed context.
require(tree.treeHash().contentEquals(groupContext.treeHash)) {
"externalJoin tree_hash does not match ratchet_tree extension"
}
// Extract external_pub from extensions // Extract external_pub from extensions
val externalPubExt = val externalPubExt =
groupInfo.extensions.find { it.extensionType == EXTERNAL_PUB_EXTENSION_TYPE } groupInfo.extensions.find { it.extensionType == EXTERNAL_PUB_EXTENSION_TYPE }
@@ -2303,10 +2672,11 @@ class MlsGroup private constructor(
) )
val commitBytes = commit.toTlsBytes() val commitBytes = commit.toTlsBytes()
// Derive epoch secrets using external init_secret // Derive epoch secrets using external init_secret.
// commit_secret = DeriveSecret(root_path_secret, "path") (RFC 9420 §9.2).
val commitSecret = val commitSecret =
if (pathSecrets.isNotEmpty()) { if (pathSecrets.isNotEmpty()) {
pathSecrets.last().pathSecret MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
} else { } else {
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
} }
@@ -2444,17 +2814,75 @@ class MlsGroup private constructor(
} }
/** /**
* Remove self from the group. * Build a standalone SelfRemove proposal framed as a PublicMessage MLS
* message (RFC 9420 §6.2 + draft-ietf-mls-extensions).
* *
* Per MIP-01/MIP-03, admins must self-demote first; this helper rejects * openmls/mdk explicitly treat SelfRemove as a STANDALONE proposal
* calls from a member currently listed in `admin_pubkeys`. * message, not a commit body: a non-admin committer can't self-remove
* (openmls returns `RequiredPathNotFound`/`AttemptedSelfRemoval`) so
* quartz must publish it as a plain PROPOSAL instead. Admin receivers
* (wn's mdk auto-commit path) pick up the staged proposal and fold it
* into their next commit, which is what actually removes the sender.
*
* The bytes returned are the full `MlsMessage(PublicMessage(proposal))`
* ready for outer ChaCha20 wrapping as kind:445 content. The second
* return value is the epoch this message must be outer-encrypted under.
*/ */
fun selfRemove(): ByteArray { fun buildSelfRemoveProposalMessage(): Pair<ByteArray, ByteArray> {
check(!isLocalAdmin()) { check(!isLocalAdmin()) {
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
} }
val preCommitExporterSecret =
exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
val proposal = Proposal.SelfRemove() val proposal = Proposal.SelfRemove()
return proposal.toTlsBytes() val proposalBytes = proposal.toTlsBytes()
val ctx = groupContext
val ctxBytes = ctx.toTlsBytes()
val preCommitMembershipKey = epochSecrets.membershipKey
val preCommitSigningKey = signingPrivateKey
// FramedContentTBS for a member-sender PROPOSAL over PublicMessage:
// version || wire_format || FramedContent || serialized_context
val tbsWriter = TlsWriter()
tbsWriter.putUint16(MlsMessage.MLS_VERSION_10)
tbsWriter.putUint16(WireFormat.PUBLIC_MESSAGE.value)
tbsWriter.putOpaqueVarInt(ctx.groupId)
tbsWriter.putUint64(ctx.epoch)
encodeSender(tbsWriter, Sender(SenderType.MEMBER, myLeafIndex))
tbsWriter.putOpaqueVarInt(ByteArray(0)) // authenticated_data
tbsWriter.putUint8(ContentType.PROPOSAL.value)
tbsWriter.putBytes(proposalBytes) // proposal struct, no outer length prefix
tbsWriter.putBytes(ctxBytes) // member sender appends context
val tbs = tbsWriter.toByteArray()
val signature = MlsCryptoProvider.signWithLabel(preCommitSigningKey, "FramedContentTBS", tbs)
// TBM = TBS || FramedContentAuthData. For PROPOSAL there's no
// confirmation_tag, just the signature.
val tbmWriter = TlsWriter()
tbmWriter.putBytes(tbs)
tbmWriter.putOpaqueVarInt(signature)
val tbm = tbmWriter.toByteArray()
val macInstance = MacInstance("HmacSHA256", preCommitMembershipKey)
macInstance.update(tbm)
val membershipTag = macInstance.doFinal()
val publicMessage =
PublicMessage(
groupId = ctx.groupId,
epoch = ctx.epoch,
sender = Sender(SenderType.MEMBER, myLeafIndex),
authenticatedData = ByteArray(0),
contentType = ContentType.PROPOSAL,
content = proposalBytes,
signature = signature,
confirmationTag = null,
membershipTag = membershipTag,
)
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes() to preCommitExporterSecret
} }
} }
@@ -174,10 +174,11 @@ class MlsGroupManager(
nostrGroupId: HexKey, nostrGroupId: HexKey,
identity: ByteArray, identity: ByteArray,
signingKey: ByteArray? = null, signingKey: ByteArray? = null,
initialExtensions: List<com.vitorpamplona.quartz.marmot.mls.tree.Extension> = emptyList(),
): MlsGroup = ): MlsGroup =
mutex.withLock { mutex.withLock {
Log.d(TAG) { "createGroup($nostrGroupId): creating new MLS group" } Log.d(TAG) { "createGroup($nostrGroupId): creating new MLS group" }
val group = MlsGroup.create(identity, signingKey) val group = MlsGroup.create(identity, signingKey, initialExtensions)
groups[nostrGroupId] = group groups[nostrGroupId] = group
persistGroup(nostrGroupId) persistGroup(nostrGroupId)
Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" } Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" }
@@ -287,6 +288,8 @@ class MlsGroupManager(
commitBytes: ByteArray, commitBytes: ByteArray,
senderLeafIndex: Int, senderLeafIndex: Int,
confirmationTag: ByteArray, confirmationTag: ByteArray,
signature: ByteArray = ByteArray(0),
wireFormat: com.vitorpamplona.quartz.marmot.mls.framing.WireFormat = com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
) = mutex.withLock { ) = mutex.withLock {
val group = requireGroup(nostrGroupId) val group = requireGroup(nostrGroupId)
@@ -297,7 +300,7 @@ class MlsGroupManager(
// the current epoch key, wasting the finite retention slots. // the current epoch key, wasting the finite retention slots.
val retainedBefore = group.retainedSecrets() val retainedBefore = group.retainedSecrets()
group.processCommit(commitBytes, senderLeafIndex, confirmationTag) group.processCommit(commitBytes, senderLeafIndex, confirmationTag, signature, wireFormat)
pushRetainedEpoch(nostrGroupId, retainedBefore) pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId) persistGroup(nostrGroupId)
@@ -331,19 +334,40 @@ class MlsGroupManager(
mutex.withLock { mutex.withLock {
val group = requireGroup(nostrGroupId) val group = requireGroup(nostrGroupId)
// Try current epoch // Try current epoch. If we hit an exception here we MUST surface
val current = group.decryptOrNull(messageBytes) // it — commits that throw mid-processCommit leave the in-memory
if (current != null) return@withLock current // group half-mutated, and a retry via `group.decrypt(...)` will
// just report a stale "epoch mismatch" from the partial advance,
// hiding the real bug. Capture the original throwable, try
// retained epochs as a fallback, and re-raise the captured one
// if nothing decrypts.
val retainedBefore = group.retainedSecrets()
val preEpoch = group.epoch
val currentFailure: Throwable? =
try {
val result = group.decrypt(messageBytes)
// PrivateMessage commits apply inline through
// `MlsGroup.decrypt` → `processCommit`; the epoch advances
// in memory but the CLI reopens a fresh Context on every
// command, so we MUST persist here or reloaded state
// silently reverts to the pre-commit extensions (including
// admin list).
if (result.contentType == com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT && group.epoch != preEpoch) {
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
}
return@withLock result
} catch (t: Throwable) {
t
}
// Try retained epochs
val retained = retainedEpochs[nostrGroupId] ?: emptyList() val retained = retainedEpochs[nostrGroupId] ?: emptyList()
for (epochSecrets in retained) { for (epochSecrets in retained) {
val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets) val result = tryDecryptWithRetainedEpoch(messageBytes, epochSecrets)
if (result != null) return@withLock result if (result != null) return@withLock result
} }
// No epoch could decrypt — rethrow from current epoch for diagnostics throw currentFailure ?: IllegalStateException("Decrypt failed without captured cause")
group.decrypt(messageBytes)
} }
/** /**
@@ -441,16 +465,17 @@ class MlsGroupManager(
} }
/** /**
* Leave a group (self-remove). * Leave a group by publishing a standalone PublicMessage SelfRemove
* Returns the SelfRemove proposal bytes to publish, then removes * proposal (draft-ietf-mls-extensions). Admin receivers auto-commit
* local state. * the pending proposal; the caller publishes the framed bytes as the
* kind:445 content, outer-encrypted with the returned exporter key.
*/ */
suspend fun leaveGroup(nostrGroupId: HexKey): ByteArray = suspend fun leaveGroup(nostrGroupId: HexKey): Pair<ByteArray, ByteArray> =
mutex.withLock { mutex.withLock {
val group = requireGroup(nostrGroupId) val group = requireGroup(nostrGroupId)
val proposalBytes = group.selfRemove() val result = group.buildSelfRemoveProposalMessage()
removeGroupStateUnlocked(nostrGroupId) removeGroupStateUnlocked(nostrGroupId)
proposalBytes result
} }
/** /**
@@ -45,8 +45,11 @@ enum class ProposalType(
EXTERNAL_INIT(6), EXTERNAL_INIT(6),
GROUP_CONTEXT_EXTENSIONS(7), GROUP_CONTEXT_EXTENSIONS(7),
// Marmot custom proposal types (private-use range 0xF000-0xFFFF) // SelfRemove is standardized in MLS Extensions draft-ietf-mls-extensions
SELF_REMOVE(0xF001), // as IANA proposal type 0x000A, NOT a Marmot private-use value.
// openmls / mdk encode it as 0x000A on the wire; quartz was writing
// 0xF001, which strict receivers reject as "Unknown ProposalType".
SELF_REMOVE(0x000A),
; ;
companion object { companion object {
@@ -50,17 +50,21 @@ class SecretTree(
/** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */ /** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */
private val senderState = mutableMapOf<Int, SenderRatchetState>() private val senderState = mutableMapOf<Int, SenderRatchetState>()
/** Consumed (sender, generation) pairs for replay detection (RFC 9420 Section 9.1) */ /** Consumed (sender, generation) pairs for replay detection on the APPLICATION ratchet. */
private val consumedGenerations = mutableMapOf<Int, MutableSet<Int>>() private val consumedGenerations = mutableMapOf<Int, MutableSet<Int>>()
/** Same replay tracker, but for the HANDSHAKE ratchet (commits / proposals). */
private val consumedHandshakeGenerations = mutableMapOf<Int, MutableSet<Int>>()
/** /**
* Cache of key/nonce pairs for skipped generations. * Cache of key/nonce pairs for skipped APPLICATION generations.
* Key: (leafIndex, generation) -> derived KeyNonceGeneration. * Key: (leafIndex, generation) -> derived KeyNonceGeneration.
* When fast-forwarding a ratchet, intermediate generations are saved here
* so that out-of-order messages arriving later can still be decrypted.
*/ */
private val skippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>() private val skippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>()
/** Same cache for the HANDSHAKE ratchet. */
private val handshakeSkippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>()
private companion object { private companion object {
/** Maximum number of skipped key entries to retain (prevents unbounded memory growth). */ /** Maximum number of skipped key entries to retain (prevents unbounded memory growth). */
const val MAX_SKIPPED_KEYS = 1000 const val MAX_SKIPPED_KEYS = 1000
@@ -186,6 +190,70 @@ class SecretTree(
return result return result
} }
/**
* Handshake-ratchet counterpart to [applicationKeyNonceForGeneration].
*
* PrivateMessage commits / proposals (RFC 9420 §6.3.2) are encrypted
* with a separate handshake ratchet per sender leaf NOT the
* application ratchet. openmls / mdk use this path by default
* (`MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY`), so quartz must also
* ratchet-forward the handshake chain when decrypting an inbound
* PrivateMessage commit.
*/
fun handshakeKeyNonceForGeneration(
leafIndex: Int,
generation: Int,
): KeyNonceGeneration {
val cachedKey = handshakeSkippedKeys.remove(Pair(leafIndex, generation))
if (cachedKey != null) {
val senderConsumed = consumedHandshakeGenerations.getOrPut(leafIndex) { mutableSetOf() }
require(generation !in senderConsumed) {
"Replay detected: handshake generation $generation from sender $leafIndex already consumed"
}
senderConsumed.add(generation)
return cachedKey
}
val state = getOrInitSender(leafIndex)
require(generation >= state.handshakeGeneration) {
"Handshake generation $generation already consumed (current: ${state.handshakeGeneration})"
}
val senderConsumed = consumedHandshakeGenerations.getOrPut(leafIndex) { mutableSetOf() }
require(generation !in senderConsumed) {
"Replay detected: handshake generation $generation from sender $leafIndex already consumed"
}
senderConsumed.add(generation)
if (senderConsumed.size > MAX_CONSUMED_GENERATIONS_PER_SENDER) {
val minGeneration = state.handshakeGeneration
senderConsumed.removeAll { it < minGeneration }
}
var secret = state.handshakeSecret
var gen = state.handshakeGeneration
while (gen < generation) {
val intermediateKng = deriveKeyNonce(secret, gen)
val cacheKey = Pair(leafIndex, gen)
if (handshakeSkippedKeys.size < MAX_SKIPPED_KEYS) {
handshakeSkippedKeys[cacheKey] = intermediateKng
}
secret = MlsCryptoProvider.expandWithLabel(secret, "secret", generationContext(gen), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
gen++
}
val result = deriveKeyNonce(secret, generation)
val nextSecret = MlsCryptoProvider.expandWithLabel(secret, "secret", generationContext(generation), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
senderState[leafIndex] =
state.copy(
handshakeSecret = nextSecret,
handshakeGeneration = generation + 1,
)
return result
}
/** /**
* Encode a generation counter as a 4-byte big-endian uint32 for DeriveTreeSecret context. * Encode a generation counter as a 4-byte big-endian uint32 for DeriveTreeSecret context.
*/ */
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
/** /**
* MLS Ratchet Tree (RFC 9420 Section 7). * MLS Ratchet Tree (RFC 9420 Section 7).
@@ -157,17 +156,55 @@ class RatchetTree(
/** /**
* Remove a member by blanking their leaf and all parent nodes on the direct path. * Remove a member by blanking their leaf and all parent nodes on the direct path.
*
* RFC 9420 §7.8: trailing blank leaves MUST be trimmed so every participant
* agrees on `leaf_count` (and therefore on `direct_path` lengths). Openmls
* does this after every leaf blank; without the trim, a committer that had
* the removed leaf at the far right end of the tree sends an UpdatePath one
* entry longer than the receiver's tree layout accepts, and the receiver
* errors out with `UpdatePathError(PathLengthMismatch)`.
*/ */
fun removeLeaf(leafIndex: Int) { fun removeLeaf(leafIndex: Int) {
setLeaf(leafIndex, null) setLeaf(leafIndex, null)
// Blank the direct path
val directPath = BinaryTree.directPath(leafIndex, _leafCount) val directPath = BinaryTree.directPath(leafIndex, _leafCount)
for (nodeIdx in directPath) { for (nodeIdx in directPath) {
if (nodeIdx < nodes.size) { if (nodeIdx < nodes.size) {
nodes[nodeIdx] = null nodes[nodeIdx] = null
} }
} }
// Shrink leafCount past any trailing blanks. Parent nodes that become
// orphaned by the shrink are dropped from the `nodes` list so
// treeHash() / resolution() / direct_path() agree with openmls on the
// new tree shape.
while (_leafCount > 0 && getLeaf(_leafCount - 1) == null) {
_leafCount--
} }
val effectiveNodeCount = if (_leafCount > 0) BinaryTree.nodeCount(_leafCount) else 0
while (nodes.size > effectiveNodeCount) {
nodes.removeAt(nodes.size - 1)
}
}
/**
* Snapshot the mutable tree state so callers can roll back after a
* failed commit application. `TreeNode`, `LeafNode`, and `ParentNode`
* are all immutable data classes copying the `nodes` list is enough
* to isolate future edits.
*/
fun snapshot(): Snapshot = Snapshot(nodes.toList(), _leafCount)
/** Restore the mutable tree state produced by an earlier [snapshot]. */
fun restoreFrom(snapshot: Snapshot) {
nodes.clear()
nodes.addAll(snapshot.nodes)
_leafCount = snapshot.leafCount
}
/** Opaque capture of the ratchet tree's mutable state. */
class Snapshot internal constructor(
internal val nodes: List<TreeNode?>,
internal val leafCount: Int,
)
/** /**
* Compute the tree hash for this ratchet tree (RFC 9420 Section 7.9). * Compute the tree hash for this ratchet tree (RFC 9420 Section 7.9).
@@ -278,15 +315,20 @@ class RatchetTree(
var currentSecret = leafSecret var currentSecret = leafSecret
for (nodeIdx in directPath) { for (nodeIdx in directPath) {
// RFC 9420 Section 7.4: path_secret[0] = leafSecret, // RFC 9420 §7.4: path_secret[0] = leafSecret,
// node_secret[n] = DeriveSecret(path_secret[n], "node") // node_secret[n] = DeriveSecret(path_secret[n], "node"),
// node's HPKE keypair = DeriveKeyPair(node_secret).
// DeriveKeyPair is HPKE's (RFC 9180 §7.1.3), NOT an MLS ExpandWithLabel —
// it uses the "HPKE-v1" + KEM suite_id labels, and produces a keypair
// that openmls/mdk can reproduce from the same path_secret. If we derive
// with a different formula, receivers compute different public keys and
// reject the UpdatePath with `UpdatePathError(PathMismatch)`.
val nodeSecret = MlsCryptoProvider.deriveSecret(currentSecret, "node") val nodeSecret = MlsCryptoProvider.deriveSecret(currentSecret, "node")
val kp =
com.vitorpamplona.quartz.marmot.mls.crypto.Hpke
.deriveKeyPair(nodeSecret)
// Derive HPKE key pair from node_secret results.add(PathSecretAndKey(currentSecret, kp.privateKey, kp.publicKey))
val privateKey = MlsCryptoProvider.expandWithLabel(nodeSecret, "hpke", ByteArray(0), 32)
val publicKey = X25519.publicFromPrivate(privateKey)
results.add(PathSecretAndKey(currentSecret, privateKey, publicKey))
// path_secret[n+1] = DeriveSecret(path_secret[n], "path") // path_secret[n+1] = DeriveSecret(path_secret[n], "path")
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path") currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
@@ -0,0 +1,291 @@
/*
* 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.quartz.marmot.mls.group
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Negative tests: every post-RFC-9420-§6 authenticity check that
* [MlsGroup.processCommit] performs should reject the tampered input AND
* leave the group in its pre-commit state (atomic rollback no partial
* epoch advance, no mutated tree, no dangling pending proposals).
*/
class MlsGroupNegativeTest {
private data class TwoMemberFixture(
val alice: MlsGroup,
val bob: MlsGroup,
val charliePkg: ByteArray,
)
/**
* Produce a 2-member group (alice creator, bob joined via Welcome)
* plus a spare KeyPackage for Charlie. Callers use Charlie's bundle to
* drive Alice into producing a fresh Add commit that Bob will process.
*/
private fun twoMemberGroup(): TwoMemberFixture {
val alice = MlsGroup.create(identity = "alice".encodeToByteArray())
val bobBundle = alice.createKeyPackage(identity = "bob".encodeToByteArray(), signingKey = ByteArray(32) { 1 })
val addBob = alice.addMember(bobBundle.keyPackage.toTlsBytes())
val bob = MlsGroup.processWelcome(addBob.welcomeBytes!!, bobBundle)
val charlieBundle =
alice.createKeyPackage(identity = "charlie".encodeToByteArray(), signingKey = ByteArray(32) { 2 })
return TwoMemberFixture(alice, bob, charlieBundle.keyPackage.toTlsBytes())
}
/**
* Decode a framedCommitBytes (MlsMessage(PublicMessage(commit))) into
* the fields [MlsGroup.processCommit] consumes. Tests re-use this to
* mutate individual fields before re-invoking.
*/
private data class CommitParts(
val content: ByteArray,
val senderLeafIndex: Int,
val confirmationTag: ByteArray,
val signature: ByteArray,
val pubMsg: PublicMessage,
)
private fun parseCommit(framedBytes: ByteArray): CommitParts {
val mlsMsg = MlsMessage.decodeTls(TlsReader(framedBytes))
val pub = PublicMessage.decodeTls(TlsReader(mlsMsg.payload))
return CommitParts(
content = pub.content,
senderLeafIndex = pub.sender.leafIndex,
confirmationTag = pub.confirmationTag!!,
signature = pub.signature,
pubMsg = pub,
)
}
/** Baseline: an honest commit applies and advances Bob's epoch. */
@Test
fun honestCommitIsAccepted() {
val fx = twoMemberGroup()
val bobEpochBefore = fx.bob.epoch
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = parts.confirmationTag,
signature = parts.signature,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
assertEquals(bobEpochBefore + 1, fx.bob.epoch)
}
/**
* Tampered confirmation_tag flipping a single bit must fail the
* HMAC compare, throw, and leave Bob on the original epoch.
*/
@Test
fun tamperedConfirmationTagIsRejectedAndStateRolledBack() {
val fx = twoMemberGroup()
val epochBefore = fx.bob.epoch
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
val tamperedTag = parts.confirmationTag.copyOf().apply { this[0] = (this[0].toInt() xor 0x01).toByte() }
assertFailsWith<IllegalArgumentException> {
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = tamperedTag,
signature = parts.signature,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
}
assertEquals(epochBefore, fx.bob.epoch)
// And the honest commit still applies cleanly afterwards — proving
// no mutable state leaked across the failed attempt.
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = parts.confirmationTag,
signature = parts.signature,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
assertEquals(epochBefore + 1, fx.bob.epoch)
}
/**
* Tampered FramedContentTBS signature receiver reconstructs the
* exact same TBS bytes the sender signed; any bit-flip in the
* signature fails Ed25519 verification.
*/
@Test
fun tamperedSignatureIsRejectedAndStateRolledBack() {
val fx = twoMemberGroup()
val epochBefore = fx.bob.epoch
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
val tamperedSig = parts.signature.copyOf().apply { this[0] = (this[0].toInt() xor 0x80).toByte() }
assertFailsWith<IllegalArgumentException> {
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = parts.confirmationTag,
signature = tamperedSig,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
}
assertEquals(epochBefore, fx.bob.epoch)
}
/**
* Claiming the commit came from the wrong leaf index the signature
* was bound to the original sender leaf, so verifying against a
* different leaf's pre-commit signatureKey fails.
*/
@Test
fun spoofedSenderLeafIndexIsRejected() {
val fx = twoMemberGroup()
val epochBefore = fx.bob.epoch
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
// Alice is leaf 0, Bob is leaf 1. Swap.
val wrongLeaf = if (parts.senderLeafIndex == 0) 1 else 0
assertFailsWith<IllegalArgumentException> {
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = wrongLeaf,
confirmationTag = parts.confirmationTag,
signature = parts.signature,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
}
assertEquals(epochBefore, fx.bob.epoch)
}
/**
* Declaring the wrong wire_format at the receiver the FramedContentTBS
* hash diverges and signature verification fails, because the sender
* mixed wire_format=PUBLIC_MESSAGE into their TBS bytes.
*/
@Test
fun wrongWireFormatIsRejected() {
val fx = twoMemberGroup()
val epochBefore = fx.bob.epoch
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
assertFailsWith<IllegalArgumentException> {
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = parts.confirmationTag,
signature = parts.signature,
wireFormat = WireFormat.PRIVATE_MESSAGE,
)
}
assertEquals(epochBefore, fx.bob.epoch)
}
/**
* Empty confirmation_tag is rejected explicitly RFC 9420 §6 requires
* every commit to carry a confirmation_tag, and an omitted tag means
* the receiver has no authentication of the post-commit epoch secrets.
*/
@Test
fun emptyConfirmationTagIsRejected() {
val fx = twoMemberGroup()
val epochBefore = fx.bob.epoch
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
assertFailsWith<IllegalArgumentException> {
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = ByteArray(0),
signature = parts.signature,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
}
assertEquals(epochBefore, fx.bob.epoch)
}
/**
* Tampered membership_tag PublicMessage commits from a member must
* carry an HMAC(membership_key, TBM) that matches what the receiver
* can reconstruct from the current epoch's membership_key. Without
* this check, an outsider that learned the outer exporter secret
* could forge arbitrary commit bodies.
*
* This is enforced at the MarmotInboundProcessor layer before
* [MlsGroup.processCommit] is called; the group exposes
* [MlsGroup.verifyPublicMessageCommitMembershipTag] so the processor
* can short-circuit on tag mismatch.
*/
@Test
fun tamperedMembershipTagFailsPublicMessageCheck() {
val fx = twoMemberGroup()
val commit = fx.alice.addMember(fx.charliePkg)
val parts = parseCommit(commit.framedCommitBytes)
// Honest tag is valid.
assertTrue(fx.bob.verifyPublicMessageCommitMembershipTag(parts.pubMsg))
// Flip one bit in the wire tag — must be rejected.
val original = parts.pubMsg.membershipTag!!
val tampered = original.copyOf().apply { this[0] = (this[0].toInt() xor 0x01).toByte() }
val badPub = parts.pubMsg.copy(membershipTag = tampered)
assertFalse(fx.bob.verifyPublicMessageCommitMembershipTag(badPub))
// A missing tag is also rejected.
val missingPub = parts.pubMsg.copy(membershipTag = null)
assertFalse(fx.bob.verifyPublicMessageCommitMembershipTag(missingPub))
// And the honest commit still applies — nothing got mutated by the checks.
val epochBefore = fx.bob.epoch
fx.bob.processCommit(
commitBytes = parts.content,
senderLeafIndex = parts.senderLeafIndex,
confirmationTag = parts.confirmationTag,
signature = parts.signature,
wireFormat = WireFormat.PUBLIC_MESSAGE,
)
assertEquals(epochBefore + 1, fx.bob.epoch)
assertNotEquals(epochBefore, fx.bob.epoch)
}
}
@@ -173,7 +173,7 @@ class MarmotMipBehaviorTest {
val alice = manager.getGroup(groupId)!! val alice = manager.getGroup(groupId)!!
assertFailsWith<IllegalStateException> { alice.proposeSelfRemove() } assertFailsWith<IllegalStateException> { alice.proposeSelfRemove() }
assertFailsWith<IllegalStateException> { alice.selfRemove() } assertFailsWith<IllegalStateException> { alice.buildSelfRemoveProposalMessage() }
} }
@Test @Test
@@ -187,8 +187,8 @@ class MarmotMipBehaviorTest {
manager.updateGroupExtensions(groupId, listOf(strangerAdmin.toExtension())) manager.updateGroupExtensions(groupId, listOf(strangerAdmin.toExtension()))
val alice = manager.getGroup(groupId)!! val alice = manager.getGroup(groupId)!!
// selfRemove (standalone proposal helper) should succeed for a non-admin. // Standalone SelfRemove proposal helper should succeed for a non-admin.
val bytes = alice.selfRemove() val (bytes, _) = alice.buildSelfRemoveProposalMessage()
assertTrue(bytes.isNotEmpty()) assertTrue(bytes.isNotEmpty())
} }
@@ -272,7 +272,7 @@ class MlsGroupTest {
@Test @Test
fun testSelfRemove() { fun testSelfRemove() {
val group = MlsGroup.create("alice".encodeToByteArray()) val group = MlsGroup.create("alice".encodeToByteArray())
val selfRemoveBytes = group.selfRemove() val (selfRemoveBytes, _) = group.buildSelfRemoveProposalMessage()
assertTrue(selfRemoveBytes.isNotEmpty()) assertTrue(selfRemoveBytes.isNotEmpty())
} }
@@ -0,0 +1,24 @@
--- a/src/whitenoise/relays.rs
+++ b/src/whitenoise/relays.rs
@@ -98,6 +98,21 @@
}
pub(crate) fn defaults() -> Vec<Relay> {
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
+ // (comma-separated list) when present so newly created accounts only
+ // ever get our loopback relay baked into their NIP-65 / inbox /
+ // key-package lists. Without this override, `create-identity` stamps
+ // the hard-coded public set into the account's relay lists, and every
+ // later activate / publish burns connection budget on unreachable
+ // sockets — enough to break inbox-plane activation and drop kind:1059.
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
+ let parsed: Vec<Relay> = from_env
+ .split(',').map(str::trim).filter(|s| !s.is_empty())
+ .filter_map(|u| RelayUrl::parse(u).ok())
+ .map(|url| Relay::new(&url))
+ .collect();
+ if !parsed.is_empty() { return parsed; }
+ }
let urls: &[&str] = if cfg!(debug_assertions) {
&["ws://localhost:8080", "ws://localhost:7777"]
} else {
@@ -0,0 +1,11 @@
--- a/../../../.cargo/git/checkouts/mdk-7d5a3a2420b194f5/8a8d06c/crates/mdk-core/src/groups.rs
+++ b/../../../.cargo/git/checkouts/mdk-7d5a3a2420b194f5/8a8d06c/crates/mdk-core/src/groups.rs
@@ -1215,7 +1215,7 @@
);
let group_config = MlsGroupCreateConfig::builder()
.ciphersuite(self.ciphersuite)
- .wire_format_policy(MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY)
+ .wire_format_policy(MIXED_PLAINTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.capabilities(capabilities)
.with_group_context_extensions(extensions)
@@ -0,0 +1,26 @@
--- a/src/whitenoise/event_processor/account_event_processor.rs
+++ b/src/whitenoise/event_processor/account_event_processor.rs
@@ -178,7 +178,23 @@
}
Err(e) => {
// Handle retry logic for actual processing errors
- if retry_info.should_retry() {
+ // marmot-interop-headless patch: MLS errors that come from
+ // mdk are ALREADY terminal — mdk doesn't retry internally, so
+ // any Err it returns (Unprocessable, PreviouslyFailed, decrypt
+ // failure, group-not-found, etc.) is provably permanent.
+ // Retrying those 10 times with exponential backoff (total
+ // ~17 min) just blocks later decryptable commits behind a
+ // queue of doomed retries, so every later join / rename /
+ // leave propagation races the test timeout. Treat them all
+ // as one-shot: log once, move on.
+ let is_terminal = matches!(
+ e,
+ WhitenoiseError::MlsMessageUnprocessable(_)
+ | WhitenoiseError::MlsMessagePreviouslyFailed
+ | WhitenoiseError::MdkCoreError(_),
+ );
+ if !is_terminal && retry_info.should_retry() {
self.schedule_retry(event, source, retry_info, e);
} else {
tracing::error!(
+22 -1
View File
@@ -39,7 +39,7 @@ preflight() {
2>&1 | tee -a "$LOG_FILE" 2>&1 | tee -a "$LOG_FILE"
fi fi
# Two harness-only patches to wnd so it runs fully offline / in # Four harness-only patches to wnd so it runs fully offline / in
# sandboxes that block outbound + kernel keyring: # sandboxes that block outbound + kernel keyring:
# 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can # 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can
# point wnd at our loopback relay instead of the baked-in public # point wnd at our loopback relay instead of the baked-in public
@@ -47,9 +47,25 @@ preflight() {
# 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the # 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
# integration-tests mock keyring store when the kernel keyutils # integration-tests mock keyring store when the kernel keyutils
# syscalls are blocked (common in containers / CI). # syscalls are blocked (common in containers / CI).
# 3. defaults-env: reuse the same env var so `Relay::defaults()`
# (what `create-identity` stamps into the new account's NIP-65 /
# inbox / key-package lists) points at the loopback relay too.
# Without it every account wnd creates carries damus.io /
# primal.net / nos.lol, and every later activate / publish burns
# connection budget on unreachable sockets — enough to break the
# account-inbox subscription plane and drop kind:1059 delivery.
# 4. skip-unprocessable-retry: when mdk-core returns
# `MlsMessageUnprocessable` (pre-membership commit, too-old epoch)
# the message is provably undecryptable — retrying it ten times
# with exponential backoff (total ~17 min) just blocks later
# decryptable commits behind a queue of doomed retries, which in
# the harness manifests as "A already left" / "name unchanged"
# timeouts. The patch treats that error as terminal.
local -a patches=( local -a patches=(
"whitenoise-discovery-env.patch" "whitenoise-discovery-env.patch"
"whitenoise-mock-keyring.patch" "whitenoise-mock-keyring.patch"
"whitenoise-defaults-env.patch"
"whitenoise-skip-unprocessable-retry.patch"
) )
for name in "${patches[@]}"; do for name in "${patches[@]}"; do
local marker="$WN_REPO/.headless-patched-${name%.patch}" local marker="$WN_REPO/.headless-patched-${name%.patch}"
@@ -267,4 +283,9 @@ configure_relays() {
step "publishing A's KeyPackage" step "publishing A's KeyPackage"
amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed" amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed"
# Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443
# writes and push them out on the discovery subscription so that the
# first `wn keys check` that follows actually sees them instead of
# racing the relay's WAL flush.
sleep 2
} }
+47 -23
View File
@@ -33,12 +33,19 @@ test_02_a_creates_group() {
banner "Test 02 — A creates group, invites B" banner "Test 02 — A creates group, invites B"
local id="02 A->B create+invite" local id="02 A->B create+invite"
local gid # amy keys groups by the MIP-01 nostr_group_id (`group_id`), wn (via
gid=$(amy_field '.group_id' marmot group create --name "Interop-02") || { # mdk-core) keys by the MLS GroupContext groupId (`mls_group_id`). Both
# are 32 random bytes and they are NOT the same. We need both: amy calls
# use `gid`, wn calls use `mls_gid`.
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-02") || {
record_result "$id" fail "create returned no group_id"; return record_result "$id" fail "create returned no group_id"; return
} }
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
save_state GROUP_02 "$gid" save_state GROUP_02 "$gid"
info "created group $gid" save_state GROUP_02_MLS "$mls_gid"
info "created group nostr=$gid mls=$mls_gid"
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy group add failed"; return record_result "$id" fail "amy group add failed"; return
@@ -52,16 +59,17 @@ test_02_a_creates_group() {
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
info "B joined $b_gid" info "B joined $b_gid"
# A -> B message # A -> B message. amy takes the nostr id; wait_for_message calls
# `wn messages list` which needs the MLS id.
amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || { amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return record_result "$id" fail "amy send failed"; return
} }
if ! wait_for_message B "$gid" "hello from amethyst" 30; then if ! wait_for_message B "$mls_gid" "hello from amethyst" 90; then
record_result "$id" fail "B didn't receive A's message"; return record_result "$id" fail "B didn't receive A's message"; return
fi fi
# B -> A message # B -> A message
wn_b messages send "$gid" "hello from wn" >/dev/null 2>&1 || warn "wn send returned nonzero" wn_b messages send "$mls_gid" "hello from wn" >/dev/null 2>&1 || warn "wn send returned nonzero"
if ! amy_json marmot await message "$gid" --match "hello from wn" --timeout 30 >/dev/null; then if ! amy_json marmot await message "$gid" --match "hello from wn" --timeout 30 >/dev/null; then
record_result "$id" fail "A didn't receive B's reply"; return record_result "$id" fail "A didn't receive B's reply"; return
fi fi
@@ -72,33 +80,41 @@ test_03_b_creates_group() {
banner "Test 03 — B creates group, invites A" banner "Test 03 — B creates group, invites A"
local id="03 B->A create+invite" local id="03 B->A create+invite"
local out gid # `wn groups create` returns the MLS group id (what wn's messages API
# expects). amy indexes by the MIP-01 nostr_group_id, which we only learn
# once A has processed the welcome and we can ask `amy await group` for
# it. Keep them distinct so each CLI gets the id it understands.
local out mls_gid
out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || { out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn groups create failed"; return record_result "$id" fail "wn groups create failed"; return
} }
gid=$(printf '%s' "$out" | jq_group_id) mls_gid=$(printf '%s' "$out" | jq_group_id)
if [[ -z "$gid" ]]; then if [[ -z "$mls_gid" ]]; then
record_result "$id" fail "could not parse group_id"; return record_result "$id" fail "could not parse group_id"; return
fi fi
save_state GROUP_03 "$gid" save_state GROUP_03_MLS "$mls_gid"
info "group_id: $gid" info "mls_group_id: $mls_gid"
# A: poll until it joins. # A: poll until it joins, and capture its nostr_group_id.
if ! amy_json marmot await group --name "Interop-03" --timeout 30 >/dev/null; then local a_out a_gid
a_out=$(amy_json marmot await group --name "Interop-03" --timeout 30) || {
record_result "$id" fail "A never joined B's group"; return record_result "$id" fail "A never joined B's group"; return
fi }
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id')
save_state GROUP_03 "$a_gid"
info "A joined as nostr_group_id=$a_gid"
# B -> A message # B -> A message
wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true wn_b messages send "$mls_gid" "ping from wn" >/dev/null 2>&1 || true
if ! amy_json marmot await message "$gid" --match "ping from wn" --timeout 30 >/dev/null; then if ! amy_json marmot await message "$a_gid" --match "ping from wn" --timeout 30 >/dev/null; then
record_result "$id" fail "A didn't see B's ping"; return record_result "$id" fail "A didn't see B's ping"; return
fi fi
# A -> B reply # A -> B reply
amy_json marmot message send "$gid" "pong from amethyst" >/dev/null || { amy_json marmot message send "$a_gid" "pong from amethyst" >/dev/null || {
record_result "$id" fail "amy send pong failed"; return record_result "$id" fail "amy send pong failed"; return
} }
if wait_for_message B "$gid" "pong from amethyst" 30; then if wait_for_message B "$mls_gid" "pong from amethyst" 90; then
record_result "$id" pass record_result "$id" pass
else else
record_result "$id" fail "B didn't see A's pong" record_result "$id" fail "B didn't see A's pong"
@@ -112,7 +128,9 @@ test_04_three_member_group() {
wn_c keys publish >/dev/null 2>&1 || true wn_c keys publish >/dev/null 2>&1 || true
sleep 3 sleep 3
local gid; gid=$(load_state GROUP_02 || true) local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return record_result "$id" skip "no GROUP_02"; return
fi fi
@@ -131,8 +149,14 @@ test_04_three_member_group() {
amy_json marmot message send "$gid" "hello three-member world" >/dev/null || { amy_json marmot message send "$gid" "hello three-member world" >/dev/null || {
record_result "$id" fail "amy send failed"; return record_result "$id" fail "amy send failed"; return
} }
if wait_for_message B "$gid" "hello three-member world" 30 \ # wn's event_processor retries undecryptable pre-membership commits with
&& wait_for_message C "$c_gid" "hello three-member world" 30; then # exponential backoff (2+4+8+16=~30s), and kind:445 processing is serial
# per-account; a fresh joiner routinely needs ~60s to burn through that
# retry queue before the add-C commit is applied and "hello three-member
# world" decrypts. The 30s we used for the inline A<->B send/recv
# wouldn't clear that.
if wait_for_message B "$mls_gid" "hello three-member world" 90 \
&& wait_for_message C "$c_gid" "hello three-member world" 90; then
record_result "$id" pass record_result "$id" pass
else else
record_result "$id" fail "B or C missed A's post-add message" record_result "$id" fail "B or C missed A's post-add message"
@@ -166,8 +190,8 @@ test_05_b_adds_a_existing() {
amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || { amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return record_result "$id" fail "amy send failed"; return
} }
if wait_for_message B "$gid" "joined from amethyst" 30 \ if wait_for_message B "$gid" "joined from amethyst" 90 \
&& wait_for_message C "$gid" "joined from amethyst" 30; then && wait_for_message C "$gid" "joined from amethyst" 90; then
record_result "$id" pass record_result "$id" pass
else else
record_result "$id" fail "B or C didn't see A's message" record_result "$id" fail "B or C didn't see A's message"
+90 -28
View File
@@ -7,35 +7,37 @@ test_09_reply_react_unreact() {
banner "Test 09 — reply / react / unreact" banner "Test 09 — reply / react / unreact"
local id="09 reply/react" local id="09 reply/react"
local gid; gid=$(load_state GROUP_02 || true) local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return record_result "$id" skip "no GROUP_02"; return
fi fi
# B anchors. Needs a member to be present — if Test 11 already ran and A left, # B anchors. Needs a member to be present — if Test 11 already ran and A left,
# skip cleanly so we don't double-fail. # skip cleanly so we don't double-fail.
if ! wn_b --json groups members "$gid" 2>/dev/null \ if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ | jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return record_result "$id" skip "A already left GROUP_02"; return
fi fi
wn_b messages send "$gid" "anchor for reactions" >/dev/null 2>&1 || true wn_b messages send "$mls_gid" "anchor for reactions" >/dev/null 2>&1 || true
sleep 3 sleep 3
local msg_id local msg_id
msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \ msg_id=$(wn_b --json messages list "$mls_gid" --limit 10 2>/dev/null \
| jq -r '[.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty') | jq -r '[(.result // .) | .[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty')
if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
record_result "$id" fail "couldn't find anchor message id"; return record_result "$id" fail "couldn't find anchor message id"; return
fi fi
wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true wn_b messages react "$mls_gid" "$msg_id" "🌮" >/dev/null 2>&1 || true
sleep 3 sleep 3
# amy reply # amy reply
amy_json marmot message send "$gid" "replying via amy" >/dev/null || { amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
record_result "$id" fail "amy send reply failed"; return record_result "$id" fail "amy send reply failed"; return
} }
if wait_for_message B "$gid" "replying via amy" 30; then if wait_for_message B "$mls_gid" "replying via amy" 90; then
record_result "$id" pass record_result "$id" pass
else else
record_result "$id" fail "B didn't receive reply" record_result "$id" fail "B didn't receive reply"
@@ -48,12 +50,14 @@ test_10_concurrent_commits() {
banner "Test 10 — Concurrent commits race" banner "Test 10 — Concurrent commits race"
local id="10 concurrent commits" local id="10 concurrent commits"
local gid; gid=$(load_state GROUP_02 || true) local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return record_result "$id" skip "no GROUP_02"; return
fi fi
if ! wn_b --json groups members "$gid" 2>/dev/null \ if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ | jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return record_result "$id" skip "A already left GROUP_02"; return
fi fi
@@ -62,21 +66,21 @@ test_10_concurrent_commits() {
# guarantees a deterministic outcome. # guarantees a deterministic outcome.
( amy_json marmot group rename "$gid" "race-from-amethyst" >/dev/null ) & ( amy_json marmot group rename "$gid" "race-from-amethyst" >/dev/null ) &
local a_pid=$! local a_pid=$!
( wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) & ( wn_b groups rename "$mls_gid" "race-from-wn" >/dev/null 2>&1 ) &
local b_pid=$! local b_pid=$!
wait "$a_pid" "$b_pid" 2>/dev/null || true wait "$a_pid" "$b_pid" 2>/dev/null || true
sleep 10 sleep 10
local b_name local b_name
b_name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') b_name=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .name // empty')
local a_name local a_name
a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "") a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "")
if [[ -n "$a_name" && "$a_name" == "$b_name" ]]; then if [[ -n "$a_name" && "$a_name" == "$b_name" ]]; then
info "race converged: both sides see \"$a_name\"" info "race converged: both sides see \"$a_name\""
# Verify encryption still works. # Verify encryption still works.
wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true wn_b messages send "$mls_gid" "post-race ping" >/dev/null 2>&1 || true
if amy_json marmot await message "$gid" --match "post-race ping" --timeout 15 >/dev/null; then if amy_json marmot await message "$gid" --match "post-race ping" --timeout 90 >/dev/null; then
record_result "$id" pass record_result "$id" pass
else else
record_result "$id" fail "encryption broken after race" record_result "$id" fail "encryption broken after race"
@@ -90,35 +94,39 @@ test_12_offline_catchup() {
banner "Test 12 — Offline catch-up" banner "Test 12 — Offline catch-up"
local id="12 offline catchup" local id="12 offline catchup"
# Fresh group so we don't collide with other tests. # wn-side keys groups by mls_group_id; amy-side by nostr_group_id. Learn
local out gid # the amy side from `amy await group` so later amy calls target the right
# group.
local out mls_gid a_out a_gid
out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE") out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE")
gid=$(printf '%s' "$out" | jq_group_id) mls_gid=$(printf '%s' "$out" | jq_group_id)
[[ -n "$gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; } [[ -n "$mls_gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; }
save_state GROUP_12 "$gid" save_state GROUP_12_MLS "$mls_gid"
# A joins. # A joins.
amy_json marmot await group --name "Interop-12" --timeout 30 >/dev/null || { a_out=$(amy_json marmot await group --name "Interop-12" --timeout 30) || {
record_result "$id" fail "A never received Interop-12 invite"; return record_result "$id" fail "A never received Interop-12 invite"; return
} }
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id')
save_state GROUP_12 "$a_gid"
# "Go offline" == don't invoke amy. Meanwhile B sends 5 messages + adds C + sends 3 more + rename. # "Go offline" == don't invoke amy. Meanwhile B sends 5 messages + adds C + sends 3 more + rename.
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do
wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true wn_b messages send "$mls_gid" "offline-msg-$i" >/dev/null 2>&1 || true
sleep 1 sleep 1
done done
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
for i in 6 7 8; do for i in 6 7 8; do
wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true wn_b messages send "$mls_gid" "offline-msg-$i" >/dev/null 2>&1 || true
sleep 1 sleep 1
done done
wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true wn_b groups rename "$mls_gid" "Interop-12-renamed" >/dev/null 2>&1 || true
sleep 3 sleep 3
# A comes back online — single sync pulls everything. # A comes back online — single sync pulls everything.
local show local show
show=$(amy_json marmot group show "$gid") || { show=$(amy_json marmot group show "$a_gid") || {
record_result "$id" fail "amy group show failed"; return record_result "$id" fail "amy group show failed"; return
} }
local name; name=$(printf '%s' "$show" | jq -r '.name') local name; name=$(printf '%s' "$show" | jq -r '.name')
@@ -127,7 +135,7 @@ test_12_offline_catchup() {
fi fi
# All 8 messages should be locally stored. # All 8 messages should be locally stored.
local msgs; msgs=$(amy_json marmot message list "$gid" --limit 100) local msgs; msgs=$(amy_json marmot message list "$a_gid" --limit 100)
local missing=0 local missing=0
for i in 1 2 3 4 5 6 7 8; do for i in 1 2 3 4 5 6 7 8; do
if ! printf '%s' "$msgs" | jq -e --arg t "offline-msg-$i" \ if ! printf '%s' "$msgs" | jq -e --arg t "offline-msg-$i" \
@@ -170,3 +178,57 @@ test_13_keypackage_rotation() {
record_result "$id" fail "no new KP event_id observed" record_result "$id" fail "no new KP event_id observed"
fi fi
} }
# -- Inverted-role tests ----------------------------------------------------
# Tests 0113 mostly exercise amethyst as the committer and wn as the
# receiver. The scenarios below are complementary: wn drives the state
# change and amy is the receiver that must accept, verify and apply it.
# This widens coverage for the post-fix inbound authenticity checks
# (membership_tag, FramedContentTBS signature, LeafNode lifetime,
# confirmation_tag) that now run on every commit amy processes.
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"
}
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"
}
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"
}
+63 -28
View File
@@ -7,9 +7,17 @@ test_06_member_removal() {
banner "Test 06 — Member removal + forward secrecy" banner "Test 06 — Member removal + forward secrecy"
local id="06 member removal" local id="06 member removal"
local gid; gid=$(load_state GROUP_05 || true) # MIP-03 only admins may commit Remove proposals. In GROUP_05 (wn-created
if [[ -z "${gid:-}" ]]; then # by B, A joined later) A is not an admin, so the test used to fail with
record_result "$id" skip "no GROUP_05"; return # `IllegalStateException: non-admin members may only commit...`. Test on
# GROUP_02 instead, where amy is the creator and therefore sole admin,
# and where test 04 has already added C. amy calls use the nostr id,
# wn calls use the MLS id.
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi fi
amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || { amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || {
@@ -17,10 +25,10 @@ test_06_member_removal() {
} }
# C should no longer see the group on its own member view. # C should no longer see the group on its own member view.
local deadline=$(( $(date +%s) + 60 )) removed=0 local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$gid" 2>/dev/null \ if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$C_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
removed=1; break removed=1; break
fi fi
@@ -33,13 +41,13 @@ test_06_member_removal() {
amy_json marmot message send "$gid" "after removing C" >/dev/null || { amy_json marmot message send "$gid" "after removing C" >/dev/null || {
record_result "$id" fail "amy send failed"; return record_result "$id" fail "amy send failed"; return
} }
wait_for_message B "$gid" "after removing C" 30 || { wait_for_message B "$mls_gid" "after removing C" 90 || {
record_result "$id" fail "B lost access after C's removal"; return record_result "$id" fail "B lost access after C's removal"; return
} }
# Forward secrecy: C must NOT see the post-removal message. # Forward secrecy: C must NOT see the post-removal message.
sleep 5 sleep 5
if wait_for_message C "$gid" "after removing C" 10; then if wait_for_message C "$mls_gid" "after removing C" 10; then
record_result "$id" fail "C still decrypted a post-removal message" record_result "$id" fail "C still decrypted a post-removal message"
else else
record_result "$id" pass record_result "$id" pass
@@ -50,8 +58,13 @@ test_07_metadata_rename() {
banner "Test 07 — Metadata rename round-trip (MIP-01)" banner "Test 07 — Metadata rename round-trip (MIP-01)"
local id="07 metadata rename" local id="07 metadata rename"
local gid; gid=$(load_state GROUP_02 || true) # amy was the creator of GROUP_02 so its own nostr_group_id is saved as
if [[ -z "${gid:-}" ]]; then # GROUP_02; wn keys its copy by the MLS group id saved as GROUP_02_MLS.
# Pass each CLI the id it understands.
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return record_result "$id" skip "no GROUP_02"; return
fi fi
@@ -59,9 +72,9 @@ test_07_metadata_rename() {
record_result "$id" fail "amy rename failed"; return record_result "$id" fail "amy rename failed"; return
} }
local deadline=$(( $(date +%s) + 60 )) seen="" local deadline=$(( $(date +%s) + 120 )) seen=""
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
seen=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .name // empty')
[[ "$seen" == "Interop-02-renamed" ]] && break [[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3 sleep 3
done done
@@ -69,9 +82,23 @@ test_07_metadata_rename() {
record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return
} }
# MIP-01: only admins may rename. GROUP_02 was created by amy (sole
# admin), so for B's rename to be accepted by wn's MIP-01 check amy
# must first promote B. amy adds B to admin_pubkeys via GCE, which
# the harness consumes via `marmot group promote` — equivalent to
# `wn groups promote` but issued by the quartz side. Without this
# step B's own wn silently refuses the rename on its MIP-01
# `ensure_account_is_group_admin` check, and the kind:445 is never
# published.
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "amy promote-B failed"; return
}
# Let wn apply the promote commit before issuing the rename.
sleep 3
# Now B renames back and A should pick it up. # Now B renames back and A should pick it up.
wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true wn_b groups rename "$mls_gid" "Interop-02-reverse" >/dev/null 2>&1 || true
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 60 >/dev/null; then if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 120 >/dev/null; then
record_result "$id" pass record_result "$id" pass
else else
record_result "$id" fail "A did not pick up B's rename" record_result "$id" fail "A did not pick up B's rename"
@@ -82,39 +109,45 @@ test_08_admin_promote_demote() {
banner "Test 08 — Admin promote / demote" banner "Test 08 — Admin promote / demote"
local id="08 admin promote/demote" local id="08 admin promote/demote"
local gid; gid=$(load_state GROUP_03 || true) # GROUP_03 was created by wn so both sides need different ids:
if [[ -z "${gid:-}" ]]; then # GROUP_03 → amy's nostr_group_id (captured in test 03 after
# `amy await group` returned `.group_id`)
# GROUP_03_MLS → wn's mls_group_id (wn's `groups create` output)
local a_gid mls_gid
a_gid=$(load_state GROUP_03 || true)
mls_gid=$(load_state GROUP_03_MLS || true)
if [[ -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_03"; return record_result "$id" skip "no GROUP_03"; return
fi fi
# Ensure 3 members (add C if missing). # Ensure 3 members (add C if missing).
wn_c keys publish >/dev/null 2>&1 || true wn_c keys publish >/dev/null 2>&1 || true
sleep 2 sleep 2
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
# B promotes A. # B promotes A.
wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1 || { wn_b groups promote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn promote failed"; return record_result "$id" fail "wn promote failed"; return
} }
# A should reflect the new admin set — poll via amy. # A should reflect the new admin set — poll via amy.
if ! amy_json marmot await admin "$gid" "$A_NPUB" --timeout 30 >/dev/null; then if ! amy_json marmot await admin "$a_gid" "$A_NPUB" --timeout 90 >/dev/null; then
record_result "$id" fail "A never saw itself promoted"; return record_result "$id" fail "A never saw itself promoted"; return
fi fi
# A now commits a rename — only possible if we're admin. # A now commits a rename — only possible if we're admin.
amy_json marmot group rename "$gid" "Interop-03-by-A" >/dev/null || { amy_json marmot group rename "$a_gid" "Interop-03-by-A" >/dev/null || {
record_result "$id" fail "A (now admin) could not rename"; return record_result "$id" fail "A (now admin) could not rename"; return
} }
# B demotes A. # B demotes A.
wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero" wn_b groups demote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
sleep 5 sleep 5
local admins local admins
admins=$(wn_b --json groups admins "$gid" 2>/dev/null \ admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
| jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') | jq -r '(.result // .) | .[]?.pubkey // .[]?.public_key // .[]?' | tr '\n' ' ')
if [[ "$admins" == *"$A_HEX"* ]]; then if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote" record_result "$id" fail "A still admin after demote"
else else
@@ -126,7 +159,9 @@ test_11_leave_group() {
banner "Test 11 — Leave group" banner "Test 11 — Leave group"
local id="11 leave group" local id="11 leave group"
local gid; gid=$(load_state GROUP_02 || true) local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return record_result "$id" skip "no GROUP_02"; return
fi fi
@@ -135,10 +170,10 @@ test_11_leave_group() {
record_result "$id" fail "amy leave failed"; return record_result "$id" fail "amy leave failed"; return
} }
local deadline=$(( $(date +%s) + 60 )) gone=0 local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$gid" 2>/dev/null \ if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ | jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
gone=1; break gone=1; break
fi fi
+13 -5
View File
@@ -150,6 +150,10 @@ expect_contains() {
# - plain hex string (from `groups list`) # - plain hex string (from `groups list`)
# - {"value":{"vec":[...]}} serde struct (from `groups create`) # - {"value":{"vec":[...]}} serde struct (from `groups create`)
# - flat byte array [n, ...] (from some responses) # - flat byte array [n, ...] (from some responses)
# Plus the three wrapper shapes wn actually uses:
# - {"result": {"mls_group_id": ...}} (groups create)
# - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0])
# - {"mls_group_id": ...} (bare)
# Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id) # Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id)
jq_group_id() { jq_group_id() {
local field="${1:-mls_group_id}" local field="${1:-mls_group_id}"
@@ -159,7 +163,8 @@ jq_group_id() {
[($n / 16 | floor), ($n % 16)] | [($n / 16 | floor), ($n % 16)] |
map(if . < 10 then (48 + .) else (87 + .) end) | map(if . < 10 then (48 + .) else (87 + .) end) |
implode; implode;
(.result // .) | (.group // .result // .) |
(.group // .) |
.[$f] | .[$f] |
if type == "string" then . if type == "string" then .
elif (type == "object" and (.value.vec != null)) then elif (type == "object" and (.value.vec != null)) then
@@ -190,8 +195,11 @@ wait_for_invite() {
deadline=$(( start + timeout )) deadline=$(( start + timeout ))
last_hb=$start last_hb=$start
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
# Post-v0.2 `wn --json groups invites` returns `{"result": [...]}`
# (older builds returned the bare array). Peel the wrapper when
# present so a pending invite is actually detected.
gid=$("$wnfn" --json groups invites 2>/dev/null \ gid=$("$wnfn" --json groups invites 2>/dev/null \
| jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true) | jq -c '(.result // .) | .[0] // empty' 2>/dev/null | jq_group_id || true)
if [[ -n "${gid:-}" ]]; then if [[ -n "${gid:-}" ]]; then
printf '%s\n' "$gid" printf '%s\n' "$gid"
return 0 return 0
@@ -203,7 +211,7 @@ wait_for_invite() {
local elapsed=$(( now - start )) remaining=$(( deadline - now )) local elapsed=$(( now - start )) remaining=$(( deadline - now ))
local pending local pending
pending=$("$wnfn" --json groups invites 2>/dev/null \ pending=$("$wnfn" --json groups invites 2>/dev/null \
| jq 'length' 2>/dev/null || echo "?") | jq '(.result // .) | length' 2>/dev/null || echo "?")
local recent="" local recent=""
if [[ -f "$data_dir/logs/stderr.log" ]]; then if [[ -f "$data_dir/logs/stderr.log" ]]; then
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \ recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
@@ -233,7 +241,7 @@ wait_for_message() {
fi fi
if [[ -n "${payload:-}" ]] && \ if [[ -n "${payload:-}" ]] && \
printf '%s' "$payload" | jq -e --arg n "$needle" \ printf '%s' "$payload" | jq -e --arg n "$needle" \
'.[]? | select((.content // .text // "") | contains($n))' \ '(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
return 0 return 0
fi fi
@@ -254,7 +262,7 @@ wait_for_member() {
payload=$(wn_c_json groups members "$gid" 2>/dev/null || true) payload=$(wn_c_json groups members "$gid" 2>/dev/null || true)
fi fi
if printf '%s' "${payload:-}" | jq -e --arg p "$pubkey" \ if printf '%s' "${payload:-}" | jq -e --arg p "$pubkey" \
'.[]? | select((.pubkey // .public_key // "") == $p)' \ '(.result // .) | .[]? | select((.pubkey // .public_key // "") == $p)' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
return 0 return 0
fi fi
@@ -105,3 +105,6 @@ test_10_concurrent_commits
test_11_leave_group test_11_leave_group
test_12_offline_catchup test_12_offline_catchup
test_13_keypackage_rotation test_13_keypackage_rotation
test_14_wn_removes_a
test_15_wn_member_leaves
test_16_wn_keypackage_rotation