From af983086ffd3097ed135470eafdad9266838520f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 10:48:36 -0400 Subject: [PATCH] test(mls): the group stays usable after an empty-path commit The existing test only checked the commit's shape. This one has the removed member apply its removal, then adds a new member and exchanges messages with the committer both ways, which exercises the commit-secret choice for an empty direct path. Co-Authored-By: Claude Opus 5.5 --- .../quartz/mls/group/MlsGroupNegativeTest.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt index 5fa7359019..fb075f828b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt @@ -105,6 +105,44 @@ class MlsGroupNegativeTest { assertEquals(1, fx.alice.members().size) } + /** + * The empty-path commit also changes how the committer derives the new epoch (the commit + * secret is the leaf's own path secret). No remaining member re-derives it, so the check + * is that the group stays usable: the removed member sees the removal, and a member + * added afterwards exchanges messages with the committer in both directions. + */ + @Test + fun groupKeepsWorkingAfterAnEmptyPathCommit() { + val fx = twoMemberGroup() + val removal = fx.alice.removeMember(1) + val parts = parseCommit(removal.framedCommitBytes) + fx.bob.processCommit( + commitBytes = parts.content, + senderLeafIndex = parts.senderLeafIndex, + confirmationTag = parts.confirmationTag, + signature = parts.signature, + wireFormat = WireFormat.PUBLIC_MESSAGE, + ) + // Bob applies his own removal: the empty path does not stop the tree update. + assertEquals(1, fx.bob.members().size) + + val daveBundle = fx.alice.createKeyPackage(identity = "dave".encodeToByteArray(), signingKey = ByteArray(32) { 3 }) + val addDave = fx.alice.addMember(daveBundle.keyPackage.toTlsBytes()) + val dave = MlsGroup.processWelcome(addDave.welcomeBytes!!, daveBundle) + assertEquals(fx.alice.epoch, dave.epoch) + + val fromAlice = fx.alice.encrypt("after the removal".encodeToByteArray()) + assertEquals("after the removal", dave.decrypt(fromAlice).content.decodeToString()) + val fromDave = dave.encrypt("and back".encodeToByteArray()) + assertEquals( + "and back", + fx.alice + .decrypt(fromDave) + .content + .decodeToString(), + ) + } + /** Baseline: an honest commit applies and advances Bob's epoch. */ @Test fun honestCommitIsAccepted() {