fix(mls): decrypt retained-epoch messages correctly (Test 12 offline catchup)
The `tryDecryptWithRetainedEpoch` fallback in `MlsGroupManager.decrypt`
exists precisely to cover the interop harness's Test 12 scenario —
application messages encrypted under epoch N arriving at a receiver
that has already processed a commit advancing to N+1. The primary
path throws "Message epoch X doesn't match current epoch Y", then the
manager iterates the retained epoch window and tries each.
Two bugs kept the fallback from ever succeeding:
1. sender-data ciphertext sample length. The primary decrypt path uses
`MlsCryptoProvider.HASH_OUTPUT_LENGTH` (32 bytes, = KDF.Nh for
HKDF-SHA256, per RFC 9420 §6.3.2); the retained path was using
`AEAD_KEY_LENGTH` (16). The comment on the primary path — "Using
AEAD.Nk here made sender-data decryption fail against every
spec-compliant sender" — was the same fix just never propagated
to this branch. sender-data AEAD always failed, the try/catch
swallowed it, and the fallback returned null.
2. content plaintext was returned raw. AEAD output is a
PrivateMessageContent struct (§6.3.1):
`applicationData<V> || signature<V> || padding`.
The primary decrypt path parses this with `pmcReader.readOpaqueVarInt()`
to extract applicationData; the retained path returned the whole
struct. Callers saw a length-prefixed blob with signature + zero
padding glued on — obvious in a raw diff, invisible in a pass/fail
test that only checked "decrypted is not null".
Fix both in `tryDecryptWithRetainedEpoch`. Existing quartz↔quartz
tests still pass because they don't exercise out-of-order decrypt —
this branch was effectively dead code before.
Add testDecryptRetainedEpoch_ApplicationMessageAfterCommit as a
regression: Bob encrypts at epoch N, Alice advances to N+1 via
add-member, Bob's epoch-N message then arrives at Alice — must round-
trip through the retained-epoch fallback and match the original
plaintext exactly.
This commit is contained in:
+23
-3
@@ -660,8 +660,18 @@ class MlsGroupManager(
|
|||||||
if (privMsg.epoch != retained.epoch) return null
|
if (privMsg.epoch != retained.epoch) return null
|
||||||
|
|
||||||
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
|
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
|
||||||
|
// RFC 9420 §6.3.2: ciphertext_sample is the first KDF.Nh bytes
|
||||||
|
// (32 for HKDF-SHA256), not AEAD.Nk (16). Using AEAD.Nk here made
|
||||||
|
// sender-data decryption silently fail for every retained-epoch
|
||||||
|
// message and turned the fallback path into a no-op — the
|
||||||
|
// symptom was interop Test 12 (offline catch-up): kind:9
|
||||||
|
// messages encrypted under epoch N arriving after a 1→N+1
|
||||||
|
// commit got rejected with "Message epoch X doesn't match
|
||||||
|
// current epoch Y" instead of being pulled through this path.
|
||||||
|
// Same fix already applied to MlsGroup.decrypt (line ~878);
|
||||||
|
// this branch was missed when that one was patched.
|
||||||
val ciphertextSample =
|
val ciphertextSample =
|
||||||
privMsg.ciphertext.copyOfRange(0, minOf(privMsg.ciphertext.size, MlsCryptoProvider.AEAD_KEY_LENGTH))
|
privMsg.ciphertext.copyOfRange(0, minOf(privMsg.ciphertext.size, MlsCryptoProvider.HASH_OUTPUT_LENGTH))
|
||||||
val senderDataKey =
|
val senderDataKey =
|
||||||
MlsCryptoProvider.expandWithLabel(
|
MlsCryptoProvider.expandWithLabel(
|
||||||
retained.senderDataSecret,
|
retained.senderDataSecret,
|
||||||
@@ -710,13 +720,23 @@ class MlsGroupManager(
|
|||||||
contentAad.putUint8(privMsg.contentType.value)
|
contentAad.putUint8(privMsg.contentType.value)
|
||||||
contentAad.putOpaqueVarInt(privMsg.authenticatedData)
|
contentAad.putOpaqueVarInt(privMsg.authenticatedData)
|
||||||
|
|
||||||
val plaintext =
|
val pmcBytes =
|
||||||
MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad.toByteArray(), privMsg.ciphertext)
|
MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad.toByteArray(), privMsg.ciphertext)
|
||||||
|
|
||||||
|
// AEAD plaintext is a PrivateMessageContent struct (RFC 9420
|
||||||
|
// §6.3.1): `applicationData<V> || signature<V> || padding`. The
|
||||||
|
// main decrypt path parses this and returns the inner
|
||||||
|
// applicationData; the retained-epoch branch was returning the
|
||||||
|
// raw struct, which made callers see a length-prefixed blob
|
||||||
|
// with signature + zero-padding glued onto the end. Extract the
|
||||||
|
// applicationData the same way.
|
||||||
|
val pmcReader = TlsReader(pmcBytes)
|
||||||
|
val applicationData = pmcReader.readOpaqueVarInt()
|
||||||
|
|
||||||
DecryptedMessage(
|
DecryptedMessage(
|
||||||
senderLeafIndex = senderLeafIndex,
|
senderLeafIndex = senderLeafIndex,
|
||||||
contentType = privMsg.contentType,
|
contentType = privMsg.contentType,
|
||||||
content = plaintext,
|
content = applicationData,
|
||||||
epoch = privMsg.epoch,
|
epoch = privMsg.epoch,
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
+78
@@ -158,6 +158,84 @@ class MarmotPipelineTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: the interop harness's Test 12 (offline catch-up) drove
|
||||||
|
* kind:9 application messages encrypted under epoch N into an Amethyst
|
||||||
|
* receiver that had already processed a 1→N+1 commit (e.g. add-member).
|
||||||
|
* Those epoch-N messages ought to decrypt through the retained-epoch
|
||||||
|
* fallback in [MlsGroupManager.decrypt], but the fallback was silently
|
||||||
|
* returning null because `tryDecryptWithRetainedEpoch` derived the
|
||||||
|
* sender-data sample using `AEAD_KEY_LENGTH` (16) instead of
|
||||||
|
* `HASH_OUTPUT_LENGTH` (32) — the same bug that was fixed on the
|
||||||
|
* main decrypt path earlier but never propagated to the retained
|
||||||
|
* branch. This test reproduces the flow and asserts the retained
|
||||||
|
* path actually decrypts.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testDecryptRetainedEpoch_ApplicationMessageAfterCommit() {
|
||||||
|
runBlocking {
|
||||||
|
val aliceMgr = createGroupManager()
|
||||||
|
val bobMgr = createGroupManager()
|
||||||
|
|
||||||
|
// 32-byte identities so they fit the MarmotGroupData 32-byte
|
||||||
|
// admin-pubkey slots.
|
||||||
|
val aliceId = ByteArray(32) { 0xA1.toByte() }
|
||||||
|
val bobId = ByteArray(32) { 0xB2.toByte() }
|
||||||
|
val carolId = ByteArray(32) { 0xC3.toByte() }
|
||||||
|
|
||||||
|
aliceMgr.createGroup(groupId, aliceId)
|
||||||
|
// Install the MarmotGroupData extension so Welcome messages carry
|
||||||
|
// the NostrGroupData (MlsGroupManager.processWelcome requires it).
|
||||||
|
aliceMgr.updateGroupExtensions(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
extensions =
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
.MarmotGroupData(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
adminPubkeys = listOf(aliceId.toHexKey()),
|
||||||
|
).toExtension(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bob joins at epoch 2 (epoch 0 = create, epoch 1 = GCE above,
|
||||||
|
// epoch 2 = add Bob).
|
||||||
|
val aliceGroup = aliceMgr.getGroup(groupId)!!
|
||||||
|
val bobBundle = aliceGroup.createKeyPackage(bobId, ByteArray(0))
|
||||||
|
val addBob = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
bobMgr.processWelcome(addBob.welcomeBytes!!, bobBundle)
|
||||||
|
val bobJoinEpoch = aliceMgr.getGroup(groupId)!!.epoch
|
||||||
|
assertEquals(bobJoinEpoch, bobMgr.getGroup(groupId)!!.epoch, "bob must be at alice's epoch after Welcome")
|
||||||
|
|
||||||
|
// Bob encrypts a message at his current epoch — this would be
|
||||||
|
// held by a lossy relay and only reach Alice after she's
|
||||||
|
// already processed the next commit.
|
||||||
|
val bobStaleMsg = bobMgr.encrypt(groupId, "bob's offline epoch-N message".encodeToByteArray())
|
||||||
|
|
||||||
|
// Alice adds Carol, advancing to epoch N+1. Bob's earlier
|
||||||
|
// message is now out-of-order relative to Alice's tree state.
|
||||||
|
val carolBundle = aliceGroup.createKeyPackage(carolId, ByteArray(0))
|
||||||
|
aliceMgr.addMember(groupId, carolBundle.keyPackage.toTlsBytes())
|
||||||
|
assertEquals(
|
||||||
|
bobJoinEpoch + 1,
|
||||||
|
aliceMgr.getGroup(groupId)!!.epoch,
|
||||||
|
"alice must have advanced one epoch past bob's encrypt time",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bob's stale message now arrives at Alice. The primary decrypt
|
||||||
|
// path throws "Message epoch X doesn't match current epoch X+1";
|
||||||
|
// the retained-epoch fallback inside MlsGroupManager.decrypt
|
||||||
|
// must pick it up.
|
||||||
|
val decrypted = aliceMgr.decrypt(groupId, bobStaleMsg)
|
||||||
|
assertEquals(
|
||||||
|
"bob's offline epoch-N message",
|
||||||
|
decrypted.content.decodeToString(),
|
||||||
|
"Alice must decrypt Bob's pre-commit message via retained-epoch fallback",
|
||||||
|
)
|
||||||
|
assertEquals(bobJoinEpoch, decrypted.epoch, "message was encrypted at bob's join epoch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInboundRejectsNonMemberGroup() {
|
fun testInboundRejectsNonMemberGroup() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
Reference in New Issue
Block a user