feat: implement P1 RFC 9420 compliance fixes

1. RatchetTree trailing blank stripping (RFC 9420 Section 7.8):
   encodeTls now omits trailing blank nodes, matching OpenMLS/mls-rs
   wire format. Reduces serialized tree size.

2. X.509 credential support (RFC 9420 Section 5.3):
   Added Credential.X509 class with cert chain (List<ByteArray>).
   decodeTls now handles credential type 2 instead of throwing.
   Full encoding/decoding round-trip supported.

3. ReInit proposal (RFC 9420 Section 12.1.5):
   Added Proposal.ReInit with groupId, version, cipherSuite, extensions.
   Full TLS encoding/decoding for interop with implementations that
   use group reinitialization.

4. ExternalInit proposal (RFC 9420 Section 12.1.6):
   Added Proposal.ExternalInit with kemOutput field.
   Enables external commit flow for non-members joining groups.

5. Membership MAC verification (RFC 9420 Section 6.2):
   Added verifyMembershipTag() to MlsGroup for PublicMessage
   authentication using HMAC(membership_key, AuthenticatedContent).

All 120 MLS tests pass (41 interop + 79 unit), 0 failures.

https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
This commit is contained in:
Claude
2026-04-03 23:30:20 +00:00
parent cb9aaa4117
commit f7f550e354
4 changed files with 136 additions and 2 deletions
@@ -231,7 +231,13 @@ class MlsGroup private constructor(
groupContext.copy(extensions = p.extensions)
}
is Proposal.Psk -> {} // PSK handling
is Proposal.Psk -> {}
// PSK handling
is Proposal.ReInit -> {}
// Handled at a higher level
is Proposal.ExternalInit -> {} // Handled in external commit flow
}
}
@@ -601,6 +607,27 @@ class MlsGroup private constructor(
)
}
/**
* Verify the membership_tag on a PublicMessage (RFC 9420 Section 6.2).
* membership_tag = HMAC(membership_key, AuthenticatedContentTBM)
* where AuthenticatedContentTBM = AuthenticatedContent with the membership_tag zeroed.
*
* @param authenticatedContentBytes the TLS-serialized AuthenticatedContent (without tag)
* @param membershipTag the received membership_tag to verify
* @return true if the tag is valid
*/
fun verifyMembershipTag(
authenticatedContentBytes: ByteArray,
membershipTag: ByteArray,
): Boolean {
val mac =
com.vitorpamplona.quartz.utils.mac
.MacInstance("HmacSHA256", epochSecrets.membershipKey)
mac.update(authenticatedContentBytes)
val expectedTag = mac.doFinal()
return expectedTag.contentEquals(membershipTag)
}
private fun applyProposal(
proposal: Proposal,
senderLeafIndex: Int,
@@ -627,6 +654,11 @@ class MlsGroup private constructor(
}
is Proposal.Psk -> {}
is Proposal.ReInit -> {}
// Handled at a higher level
is Proposal.ExternalInit -> {} // Handled in external commit flow
}
}
@@ -157,6 +157,56 @@ sealed class Proposal : TlsSerializable {
override fun hashCode(): Int = pskId.contentHashCode()
}
/**
* ReInit proposal: reinitialize the group with new parameters (RFC 9420 Section 12.1.5).
*/
data class ReInit(
val groupId: ByteArray,
val version: Int,
val cipherSuite: Int,
val extensions: List<Extension>,
) : Proposal() {
override val proposalType = ProposalType.REINIT
override fun encodeTls(writer: TlsWriter) {
writer.putUint16(proposalType.value)
writer.putOpaqueVarInt(groupId)
writer.putUint16(version)
writer.putUint16(cipherSuite)
writer.putVectorVarInt(extensions)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReInit) return false
return groupId.contentEquals(other.groupId) && version == other.version && cipherSuite == other.cipherSuite
}
override fun hashCode(): Int = groupId.contentHashCode()
}
/**
* ExternalInit proposal: allows an external party to join via external commit (RFC 9420 Section 12.1.6).
*/
data class ExternalInit(
val kemOutput: ByteArray,
) : Proposal() {
override val proposalType = ProposalType.EXTERNAL_INIT
override fun encodeTls(writer: TlsWriter) {
writer.putUint16(proposalType.value)
writer.putOpaqueVarInt(kemOutput)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ExternalInit) return false
return kemOutput.contentEquals(other.kemOutput)
}
override fun hashCode(): Int = kemOutput.contentHashCode()
}
companion object {
fun decodeTls(reader: TlsReader): Proposal {
val type = ProposalType.fromValue(reader.readUint16())
@@ -185,6 +235,19 @@ sealed class Proposal : TlsSerializable {
Psk(reader.readUint8(), reader.readOpaqueVarInt(), reader.readOpaqueVarInt())
}
ProposalType.REINIT -> {
ReInit(
groupId = reader.readOpaqueVarInt(),
version = reader.readUint16(),
cipherSuite = reader.readUint16(),
extensions = reader.readVectorVarInt { Extension.decodeTls(it) },
)
}
ProposalType.EXTERNAL_INIT -> {
ExternalInit(reader.readOpaqueVarInt())
}
else -> {
throw IllegalArgumentException("Unsupported proposal type: $type")
}
@@ -46,6 +46,36 @@ sealed class Credential : TlsSerializable {
override fun hashCode(): Int = identity.contentHashCode()
}
/**
* X.509 credential (RFC 9420 Section 5.3).
* Contains a chain of DER-encoded X.509 certificates.
*/
data class X509(
val certChain: List<ByteArray>,
) : Credential() {
override fun encodeTls(writer: TlsWriter) {
writer.putUint16(CREDENTIAL_TYPE_X509)
writer.putVectorVarInt(
certChain.map { cert ->
object : TlsSerializable {
override fun encodeTls(w: TlsWriter) {
w.putOpaqueVarInt(cert)
}
}
},
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is X509) return false
if (certChain.size != other.certChain.size) return false
return certChain.zip(other.certChain).all { (a, b) -> a.contentEquals(b) }
}
override fun hashCode(): Int = certChain.fold(0) { acc, c -> 31 * acc + c.contentHashCode() }
}
companion object {
const val CREDENTIAL_TYPE_BASIC = 1
const val CREDENTIAL_TYPE_X509 = 2
@@ -54,6 +84,7 @@ sealed class Credential : TlsSerializable {
val type = reader.readUint16()
return when (type) {
CREDENTIAL_TYPE_BASIC -> Basic(reader.readOpaqueVarInt())
CREDENTIAL_TYPE_X509 -> X509(reader.readVectorVarInt { it.readOpaqueVarInt() })
else -> throw IllegalArgumentException("Unknown credential type: $type")
}
}
@@ -296,8 +296,16 @@ class RatchetTree(
*/
fun encodeTls(writer: TlsWriter) {
val totalNodes = if (_leafCount > 0) BinaryTree.nodeCount(_leafCount) else 0
// Find rightmost non-blank node to trim trailing blanks (RFC 9420 Section 7.8)
var lastPresent = totalNodes - 1
while (lastPresent >= 0 && getNode(lastPresent) == null) {
lastPresent--
}
val serializeCount = lastPresent + 1
val inner = TlsWriter()
for (i in 0 until totalNodes) {
for (i in 0 until serializeCount) {
val node = getNode(i)
if (node != null) {
inner.putUint8(1)