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:
+150
@@ -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)
|
||||
}
|
||||
}
|
||||
+165
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user