diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index dea2039de..c3cc1f71e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt index c76702004..b2b0931d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt @@ -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, + ) : 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") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt index 83099c27b..82a1066b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt @@ -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, + ) : 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") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt index 9b8cc38fa..a6489ec70 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt @@ -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)