feat: implement MLS cryptographic engine in pure Kotlin (Phase 3)

Implements the core MLS (RFC 9420) engine for Marmot Protocol integration,
targeting ciphersuite 0x0001 (DHKEM-X25519, AES-128-GCM, SHA-256, Ed25519).

Components:
- codec/: TLS presentation language encoder/decoder (RFC 8446 Section 3)
- crypto/: Ed25519 signatures, X25519 ECDH, HPKE (RFC 9180), MlsCryptoProvider
  with ExpandWithLabel, DeriveSecret, SignWithLabel, EncryptWithLabel
- tree/: Left-balanced binary tree, LeafNode/ParentNode, RatchetTree with
  TreeKEM encap/decap, tree hashing, resolution, path secret derivation
- schedule/: Key schedule (epoch secret derivation chain), SecretTree
  (per-sender encryption ratchets), MLS-Exporter function
- framing/: MLSMessage, PublicMessage, PrivateMessage, content types
- messages/: Proposal (Add/Remove/Update/SelfRemove), Commit, UpdatePath,
  Welcome, GroupInfo, GroupContext, KeyPackage, GroupSecrets
- group/: MlsGroup high-level API (create, join via Welcome, add/remove
  members, encrypt/decrypt messages, export keys for Marmot outer layer)

Crypto uses expect/actual pattern: JVM/Android via java.security (EdDSA, XDH),
native platforms stubbed for future implementation. Reuses existing Quartz
primitives (AESGCM, HKDF, SHA-256, HMAC, ChaCha20-Poly1305).

Includes tests for TLS codec, binary tree arithmetic, and MLS type roundtrips.

https://claude.ai/code/session_01966YzookEUQDwszM3YCgeR
This commit is contained in:
Claude
2026-04-03 17:00:41 +00:00
parent 57a505c877
commit 119f9dd966
29 changed files with 5624 additions and 0 deletions
@@ -0,0 +1,150 @@
/*
* 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.crypto
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.EdECPrivateKeySpec
import java.security.spec.EdECPublicKeySpec
import java.security.spec.NamedParameterSpec
/**
* JVM/Android Ed25519 implementation using java.security EdDSA.
*
* Requires Java 15+ or Android API 33+.
*
* Private key format: 32-byte seed + 32-byte public key (64 bytes total).
* Public key format: 32-byte compressed Edwards point.
*/
actual object Ed25519 {
private const val ALGORITHM = "Ed25519"
private const val SEED_LENGTH = 32
private const val PUBLIC_KEY_LENGTH = 32
actual fun generateKeyPair(): Ed25519KeyPair {
val kpg = KeyPairGenerator.getInstance(ALGORITHM)
kpg.initialize(NamedParameterSpec(ALGORITHM))
val kp = kpg.generateKeyPair()
val publicKey = extractPublicKeyBytes(kp.public as java.security.interfaces.EdECPublicKey)
val seed = extractPrivateKeyBytes(kp.private as java.security.interfaces.EdECPrivateKey)
val privateKey = seed + publicKey
return Ed25519KeyPair(privateKey, publicKey)
}
actual fun sign(
message: ByteArray,
privateKey: ByteArray,
): ByteArray {
require(privateKey.size == SEED_LENGTH * 2) { "Private key must be 64 bytes (seed + public)" }
val seed = privateKey.copyOfRange(0, SEED_LENGTH)
val pubBytes = privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
val kf = KeyFactory.getInstance(ALGORITHM)
val privKeySpec = EdECPrivateKeySpec(NamedParameterSpec(ALGORITHM), seed)
val jcaPrivateKey = kf.generatePrivate(privKeySpec)
val sig = Signature.getInstance(ALGORITHM)
sig.initSign(jcaPrivateKey)
sig.update(message)
return sig.sign()
}
actual fun verify(
message: ByteArray,
signature: ByteArray,
publicKey: ByteArray,
): Boolean {
require(publicKey.size == PUBLIC_KEY_LENGTH) { "Public key must be 32 bytes" }
val kf = KeyFactory.getInstance(ALGORITHM)
val point = bytesToEdECPoint(publicKey)
val pubKeySpec = EdECPublicKeySpec(NamedParameterSpec(ALGORITHM), point)
val jcaPublicKey = kf.generatePublic(pubKeySpec)
val sig = Signature.getInstance(ALGORITHM)
sig.initVerify(jcaPublicKey)
sig.update(message)
return sig.verify(signature)
}
actual fun publicFromPrivate(privateKey: ByteArray): ByteArray {
require(privateKey.size == SEED_LENGTH * 2) { "Private key must be 64 bytes (seed + public)" }
return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
}
/**
* Extract 32-byte compressed Edwards point from JCA EdECPublicKey.
* The point encoding follows RFC 8032 Section 5.1.2.
*/
private fun extractPublicKeyBytes(pubKey: java.security.interfaces.EdECPublicKey): ByteArray {
val point = pubKey.point
val yBytes = point.y.toByteArray()
val result = ByteArray(PUBLIC_KEY_LENGTH)
// BigInteger is big-endian, Edwards encoding is little-endian
for (i in yBytes.indices) {
val targetIdx = yBytes.size - 1 - i
if (targetIdx < PUBLIC_KEY_LENGTH) {
result[targetIdx] = yBytes[i]
}
}
// Set high bit of last byte if x is odd
if (point.isXOdd) {
result[PUBLIC_KEY_LENGTH - 1] = (result[PUBLIC_KEY_LENGTH - 1].toInt() or 0x80).toByte()
}
return result
}
/**
* Extract seed bytes from JCA EdECPrivateKey.
*/
private fun extractPrivateKeyBytes(privKey: java.security.interfaces.EdECPrivateKey): ByteArray {
val bytes = privKey.bytes.orElseThrow { IllegalStateException("No seed in private key") }
return bytes.copyOf()
}
/**
* Convert 32-byte compressed Edwards point to JCA EdECPoint.
*/
private fun bytesToEdECPoint(publicKey: ByteArray): java.security.spec.EdECPoint {
// RFC 8032: last bit of last byte encodes x parity
val xOdd = (publicKey[PUBLIC_KEY_LENGTH - 1].toInt() and 0x80) != 0
// Clear the high bit and reverse to big-endian for BigInteger
val yLE = publicKey.copyOf()
yLE[PUBLIC_KEY_LENGTH - 1] = (yLE[PUBLIC_KEY_LENGTH - 1].toInt() and 0x7F).toByte()
// Reverse to big-endian
val yBE = ByteArray(PUBLIC_KEY_LENGTH)
for (i in 0 until PUBLIC_KEY_LENGTH) {
yBE[i] = yLE[PUBLIC_KEY_LENGTH - 1 - i]
}
val y = java.math.BigInteger(1, yBE)
return java.security.spec.EdECPoint(xOdd, y)
}
}
@@ -0,0 +1,165 @@
/*
* 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.crypto
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.spec.NamedParameterSpec
import java.security.spec.XECPrivateKeySpec
import java.security.spec.XECPublicKeySpec
import javax.crypto.KeyAgreement
/**
* JVM/Android X25519 implementation using java.security XDH.
*
* Requires Java 11+ or Android API 31+.
*
* Key format: raw 32-byte Curve25519 keys (little-endian per RFC 7748).
*/
actual object X25519 {
private const val ALGORITHM = "X25519"
private const val KEY_LENGTH = 32
actual fun generateKeyPair(): X25519KeyPair {
val kpg = KeyPairGenerator.getInstance("XDH")
kpg.initialize(NamedParameterSpec(ALGORITHM))
val kp = kpg.generateKeyPair()
val publicKey = extractPublicKeyBytes(kp.public as java.security.interfaces.XECPublicKey)
val privateKey = extractPrivateKeyBytes(kp.private as java.security.interfaces.XECPrivateKey)
return X25519KeyPair(privateKey, publicKey)
}
actual fun dh(
privateKey: ByteArray,
publicKey: ByteArray,
): ByteArray {
require(privateKey.size == KEY_LENGTH) { "Private key must be 32 bytes" }
require(publicKey.size == KEY_LENGTH) { "Public key must be 32 bytes" }
val kf = KeyFactory.getInstance("XDH")
// Build JCA private key from raw bytes
val privKeySpec = XECPrivateKeySpec(NamedParameterSpec(ALGORITHM), privateKey)
val jcaPrivateKey = kf.generatePrivate(privKeySpec)
// Build JCA public key from raw bytes (u-coordinate as BigInteger)
val u = bytesToBigInteger(publicKey)
val pubKeySpec = XECPublicKeySpec(NamedParameterSpec(ALGORITHM), u)
val jcaPublicKey = kf.generatePublic(pubKeySpec)
val ka = KeyAgreement.getInstance("XDH")
ka.init(jcaPrivateKey)
ka.doPhase(jcaPublicKey, true)
val secret = ka.generateSecret()
// XDH returns big-endian, X25519 shared secret is 32 bytes
// Pad or trim to KEY_LENGTH
return if (secret.size == KEY_LENGTH) {
secret
} else if (secret.size < KEY_LENGTH) {
ByteArray(KEY_LENGTH - secret.size) + secret
} else {
secret.copyOfRange(secret.size - KEY_LENGTH, secret.size)
}
}
actual fun publicFromPrivate(privateKey: ByteArray): ByteArray {
require(privateKey.size == KEY_LENGTH) { "Private key must be 32 bytes" }
val kf = KeyFactory.getInstance("XDH")
val privKeySpec = XECPrivateKeySpec(NamedParameterSpec(ALGORITHM), privateKey)
val jcaPrivateKey = kf.generatePrivate(privKeySpec)
// Generate key pair from the same seed to get the public key
val kpg = KeyPairGenerator.getInstance("XDH")
kpg.initialize(NamedParameterSpec(ALGORITHM))
// Re-derive: use the private key to create a keypair, then extract public
// Unfortunately JCA doesn't have a direct way to do this, so we use key factory
// The JCA will compute the public key when creating the key pair
// Workaround: generate a dummy keypair and use DH with basepoint
// Actually, XECPrivateKeySpec can be used with KeyFactory to get the paired public key
val kp = kf.generatePrivate(privKeySpec)
// Get the public key by computing DH with the basepoint (9)
val basepoint = ByteArray(KEY_LENGTH)
basepoint[0] = 9
return dh(privateKey, basepoint)
}
/**
* Extract 32-byte raw X25519 public key from JCA XECPublicKey.
* The u-coordinate is stored as a BigInteger, we convert to little-endian bytes.
*/
private fun extractPublicKeyBytes(pubKey: java.security.interfaces.XECPublicKey): ByteArray {
val u = pubKey.u
return bigIntegerToBytes(u)
}
/**
* Extract raw scalar bytes from JCA XECPrivateKey.
*/
private fun extractPrivateKeyBytes(privKey: java.security.interfaces.XECPrivateKey): ByteArray {
val scalar = privKey.scalar.orElseThrow { IllegalStateException("No scalar in private key") }
return if (scalar.size == KEY_LENGTH) {
scalar
} else if (scalar.size < KEY_LENGTH) {
// Pad with leading zeros
val result = ByteArray(KEY_LENGTH)
scalar.copyInto(result, KEY_LENGTH - scalar.size)
result
} else {
scalar.copyOfRange(scalar.size - KEY_LENGTH, scalar.size)
}
}
/**
* Convert little-endian 32-byte X25519 key to BigInteger for JCA.
* RFC 7748 uses little-endian, JCA uses BigInteger (unsigned).
*/
private fun bytesToBigInteger(bytes: ByteArray): java.math.BigInteger {
// Reverse to big-endian and create unsigned BigInteger
val be = ByteArray(bytes.size + 1) // prepend 0 for positive
for (i in bytes.indices) {
be[bytes.size - i] = bytes[i]
}
return java.math.BigInteger(be)
}
/**
* Convert BigInteger (u-coordinate) to 32-byte little-endian.
*/
private fun bigIntegerToBytes(bi: java.math.BigInteger): ByteArray {
val beBytes = bi.toByteArray()
val result = ByteArray(KEY_LENGTH)
// BigInteger is big-endian, possibly with leading zero byte
val start = if (beBytes.size > KEY_LENGTH) beBytes.size - KEY_LENGTH else 0
val length = minOf(beBytes.size, KEY_LENGTH)
// Reverse into little-endian result
for (i in 0 until length) {
result[i] = beBytes[beBytes.size - 1 - i + start]
}
return result
}
}