Fixes key creation for groups.

This commit is contained in:
Vitor Pamplona
2026-04-15 12:04:38 -04:00
parent e9996f474b
commit db18954d91
9 changed files with 387 additions and 223 deletions
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.playback.composable.controls package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -54,6 +55,7 @@ import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
@@ -189,6 +191,7 @@ private data class QualityChoice(
val bitrate: Int, val bitrate: Int,
) )
@OptIn(UnstableApi::class)
private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoice> { private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoice> {
val choices = mutableListOf<QualityChoice>() val choices = mutableListOf<QualityChoice>()
for (i in 0 until group.length) { for (i in 0 until group.length) {
@@ -208,6 +211,7 @@ private fun formatBitrate(bitrate: Int): String =
else -> String.format(Locale.US, "%.0f kbps", bitrate / 1_000.0) else -> String.format(Locale.US, "%.0f kbps", bitrate / 1_000.0)
} }
@OptIn(UnstableApi::class)
private fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } private fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO }
private fun clearVideoOverride(player: Player) { private fun clearVideoOverride(player: Player) {
+3 -2
View File
@@ -145,6 +145,9 @@ kotlin {
// Negentropy set reconciliation (NIP-77) // Negentropy set reconciliation (NIP-77)
api(libs.negentropy.kmp) api(libs.negentropy.kmp)
implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4")
implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4")
} }
} }
@@ -323,8 +326,6 @@ kotlin {
dependencies { dependencies {
implementation(libs.net.thauvin.erik.urlencoder.lib) implementation(libs.net.thauvin.erik.urlencoder.lib)
implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal)
implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4")
implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4")
} }
} }
@@ -24,8 +24,8 @@ package com.vitorpamplona.quartz.marmot.mls.crypto
* Ed25519 digital signature operations for MLS ciphersuite 0x0001. * Ed25519 digital signature operations for MLS ciphersuite 0x0001.
* *
* Platform-specific implementations: * Platform-specific implementations:
* - JVM/Android: java.security EdDSA (Java 15+, Android API 33+) * - JVM/Android: Pure Kotlin implementation
* - Native: expect/actual with kotlinx-crypto or platform crypto * - Native: Pure Kotlin implementation
*/ */
expect object Ed25519 { expect object Ed25519 {
/** /**
@@ -20,68 +20,26 @@
*/ */
package com.vitorpamplona.quartz.marmot.mls.crypto package com.vitorpamplona.quartz.marmot.mls.crypto
import java.security.KeyFactory import com.vitorpamplona.quartz.utils.RandomInstance
import java.security.KeyPairGenerator import io.github.andreypfau.kotlinx.crypto.Sha512
import java.security.Provider
import java.security.Security
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. * JVM/Android Ed25519 implementation using pure Kotlin field arithmetic.
* *
* Requires Java 15+ or Android API 33+. * This implementation is used to avoid issues with Android's KeyStore provider,
* which requires platform-specific initialization that breaks standard JCA usage.
* *
* Private key format: 32-byte seed + 32-byte public key (64 bytes total). * Private key format: 32-byte seed + 32-byte public key (64 bytes total).
* Public key format: 32-byte compressed Edwards point. * Public key format: 32-byte compressed Edwards point.
*/ */
actual object Ed25519 { actual object Ed25519 {
private const val ALGORITHM = "Ed25519"
private const val SEED_LENGTH = 32 private const val SEED_LENGTH = 32
private const val PUBLIC_KEY_LENGTH = 32 private const val PUBLIC_KEY_LENGTH = 32
/**
* Picks a non-AndroidKeyStore provider for the given JCA service.
*
* On Android, `KeyPairGenerator.getInstance("Ed25519")` can resolve to
* `AndroidKeyStoreKeyPairGeneratorSpi`, which rejects `NamedParameterSpec`
* and requires `KeyGenParameterSpec` instead. We need a software provider
* (Conscrypt / SunEC) that supports the standard JCA Ed25519 interface.
*/
private fun findProvider(service: String): Provider? =
Security
.getProviders("$service.$ALGORITHM")
?.firstOrNull { !it.name.contains("AndroidKeyStore", ignoreCase = true) }
private val keyPairGeneratorProvider: Provider? = findProvider("KeyPairGenerator")
private val keyFactoryProvider: Provider? = findProvider("KeyFactory")
private val signatureProvider: Provider? = findProvider("Signature")
private fun keyPairGenerator(): KeyPairGenerator =
keyPairGeneratorProvider?.let { KeyPairGenerator.getInstance(ALGORITHM, it) }
?: KeyPairGenerator.getInstance(ALGORITHM)
private fun keyFactory(): KeyFactory =
keyFactoryProvider?.let { KeyFactory.getInstance(ALGORITHM, it) }
?: KeyFactory.getInstance(ALGORITHM)
private fun signatureInstance(): Signature =
signatureProvider?.let { Signature.getInstance(ALGORITHM, it) }
?: Signature.getInstance(ALGORITHM)
actual fun generateKeyPair(): Ed25519KeyPair { actual fun generateKeyPair(): Ed25519KeyPair {
// Ed25519 is fully specified by its algorithm name, so no initialize() val seed = RandomInstance.bytes(SEED_LENGTH)
// call is required. Calling initialize(NamedParameterSpec) would fail val publicKey = derivePublicKey(seed)
// on Android's keystore provider (which requires KeyGenParameterSpec).
val kpg = keyPairGenerator()
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 val privateKey = seed + publicKey
return Ed25519KeyPair(privateKey, publicKey) return Ed25519KeyPair(privateKey, publicKey)
} }
@@ -92,16 +50,36 @@ actual object Ed25519 {
require(privateKey.size == SEED_LENGTH * 2) { "Private key must be 64 bytes (seed + public)" } require(privateKey.size == SEED_LENGTH * 2) { "Private key must be 64 bytes (seed + public)" }
val seed = privateKey.copyOfRange(0, SEED_LENGTH) val seed = privateKey.copyOfRange(0, SEED_LENGTH)
val pubBytes = privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) val publicKey = privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
val kf = keyFactory() val d = sha512(seed)
val privKeySpec = EdECPrivateKeySpec(NamedParameterSpec(ALGORITHM), seed) d[0] = (d[0].toInt() and 248).toByte()
val jcaPrivateKey = kf.generatePrivate(privKeySpec) d[31] = ((d[31].toInt() and 63) or 64).toByte()
val sig = signatureInstance() val rHash = sha512(d.copyOfRange(32, 64) + message)
sig.initSign(jcaPrivateKey) val r = reduce(rHash)
sig.update(message)
return sig.sign() val rPoint = scalarMultBase(r)
val rBytes = packPoint(rPoint)
val hramHash = sha512(rBytes + publicKey + message)
val hram = reduce(hramHash)
val signature = ByteArray(64)
rBytes.copyInto(signature, 0)
val x = LongArray(64)
for (i in 0 until 32) x[i] = r[i].toLong() and 0xFF
for (i in 0 until 32) {
for (j in 0 until 32) {
x[i + j] += (hram[i].toLong() and 0xFF) * (d[j].toLong() and 0xFF)
}
}
val sBytes = modL(x)
sBytes.copyInto(signature, 32)
return signature
} }
actual fun verify( actual fun verify(
@@ -110,16 +88,25 @@ actual object Ed25519 {
publicKey: ByteArray, publicKey: ByteArray,
): Boolean { ): Boolean {
require(publicKey.size == PUBLIC_KEY_LENGTH) { "Public key must be 32 bytes" } require(publicKey.size == PUBLIC_KEY_LENGTH) { "Public key must be 32 bytes" }
if (signature.size != 64) return false
val kf = keyFactory() val aPoint = unpackPoint(publicKey) ?: return false
val point = bytesToEdECPoint(publicKey)
val pubKeySpec = EdECPublicKeySpec(NamedParameterSpec(ALGORITHM), point)
val jcaPublicKey = kf.generatePublic(pubKeySpec)
val sig = signatureInstance() val rBytes = signature.copyOfRange(0, 32)
sig.initVerify(jcaPublicKey) val sBytes = signature.copyOfRange(32, 64)
sig.update(message)
return sig.verify(signature) if (!isCanonicalScalar(sBytes)) return false
val hramHash = sha512(rBytes + publicKey + message)
val hram = reduce(hramHash)
val sPoint = scalarMultBase(sBytes)
val hramA = scalarMult(aPoint, hram)
val negHramA = negatePoint(hramA)
val rCheck = addPoints(sPoint, negHramA)
val rCheckBytes = packPoint(rCheck)
return rCheckBytes.contentEquals(rBytes)
} }
actual fun publicFromPrivate(privateKey: ByteArray): ByteArray { actual fun publicFromPrivate(privateKey: ByteArray): ByteArray {
@@ -127,57 +114,273 @@ actual object Ed25519 {
return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2)
} }
/** private fun derivePublicKey(seed: ByteArray): ByteArray {
* Extract 32-byte compressed Edwards point from JCA EdECPublicKey. val d = sha512(seed)
* The point encoding follows RFC 8032 Section 5.1.2. d[0] = (d[0].toInt() and 248).toByte()
*/ d[31] = ((d[31].toInt() and 63) or 64).toByte()
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 val p = scalarMultBase(d.copyOfRange(0, 32))
for (i in yBytes.indices) { return packPoint(p)
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 private fun sha512(data: ByteArray): ByteArray {
if (point.isXOdd) { val digest = Sha512()
result[PUBLIC_KEY_LENGTH - 1] = (result[PUBLIC_KEY_LENGTH - 1].toInt() or 0x80).toByte() digest.update(data)
} return digest.digest()
}
private fun newPoint(): Array<LongArray> =
arrayOf(
LongArray(16),
LongArray(16),
LongArray(16),
LongArray(16),
)
private fun identityPoint(): Array<LongArray> {
val p = newPoint()
p[1][0] = 1
p[2][0] = 1
return p
}
private fun addPoints(
p: Array<LongArray>,
q: Array<LongArray>,
): Array<LongArray> {
val result =
arrayOf(
p[0].copyOf(),
p[1].copyOf(),
p[2].copyOf(),
p[3].copyOf(),
)
addPointInPlace(result, q)
return result return result
} }
/** private fun addPointInPlace(
* Extract seed bytes from JCA EdECPrivateKey. p: Array<LongArray>,
*/ q: Array<LongArray>,
private fun extractPrivateKeyBytes(privKey: java.security.interfaces.EdECPrivateKey): ByteArray { ) {
val bytes = privKey.bytes.orElseThrow { IllegalStateException("No seed in private key") } val a = Curve25519Field.sub(p[1], p[0])
return bytes.copyOf() val t = Curve25519Field.sub(q[1], q[0])
val aMul = Curve25519Field.mul(a, t)
val b = Curve25519Field.add(p[0], p[1])
val t2 = Curve25519Field.add(q[0], q[1])
val bMul = Curve25519Field.mul(b, t2)
val c = Curve25519Field.mul(p[3], q[3])
val cMul = Curve25519Field.mul(c, Curve25519Field.D2)
val d = Curve25519Field.mul(p[2], q[2])
val dAdd = Curve25519Field.add(d, d)
val e = Curve25519Field.sub(bMul, aMul)
val f = Curve25519Field.sub(dAdd, cMul)
val g = Curve25519Field.add(dAdd, cMul)
val h = Curve25519Field.add(bMul, aMul)
Curve25519Field.mul(e, f).copyInto(p[0])
Curve25519Field.mul(h, g).copyInto(p[1])
Curve25519Field.mul(g, f).copyInto(p[2])
Curve25519Field.mul(e, h).copyInto(p[3])
} }
/** private fun negatePoint(p: Array<LongArray>): Array<LongArray> {
* Convert 32-byte compressed Edwards point to JCA EdECPoint. val result = newPoint()
*/ Curve25519Field.sub(Curve25519Field.GF0, p[0]).copyInto(result[0])
private fun bytesToEdECPoint(publicKey: ByteArray): java.security.spec.EdECPoint { p[1].copyInto(result[1])
// RFC 8032: last bit of last byte encodes x parity p[2].copyInto(result[2])
val xOdd = (publicKey[PUBLIC_KEY_LENGTH - 1].toInt() and 0x80) != 0 Curve25519Field.sub(Curve25519Field.GF0, p[3]).copyInto(result[3])
return result
}
// Clear the high bit and reverse to big-endian for BigInteger private fun scalarMult(
val yLE = publicKey.copyOf() p: Array<LongArray>,
yLE[PUBLIC_KEY_LENGTH - 1] = (yLE[PUBLIC_KEY_LENGTH - 1].toInt() and 0x7F).toByte() s: ByteArray,
): Array<LongArray> {
val result = identityPoint()
val q =
arrayOf(
p[0].copyOf(),
p[1].copyOf(),
p[2].copyOf(),
p[3].copyOf(),
)
for (i in 255 downTo 0) {
val b = ((s[i shr 3].toInt() shr (i and 7)) and 1).toLong()
cswap(result, q, b)
addPointInPlace(q, result)
addPointInPlace(result, result)
cswap(result, q, b)
}
return result
}
// Reverse to big-endian private fun scalarMultBase(s: ByteArray): Array<LongArray> {
val yBE = ByteArray(PUBLIC_KEY_LENGTH) val basePoint = newPoint()
for (i in 0 until PUBLIC_KEY_LENGTH) { Curve25519Field.BX.copyInto(basePoint[0])
yBE[i] = yLE[PUBLIC_KEY_LENGTH - 1 - i] Curve25519Field.BY.copyInto(basePoint[1])
Curve25519Field.GF1.copyInto(basePoint[2])
Curve25519Field.mul(Curve25519Field.BX, Curve25519Field.BY).copyInto(basePoint[3])
return scalarMult(basePoint, s)
}
private fun cswap(
p: Array<LongArray>,
q: Array<LongArray>,
b: Long,
) {
for (i in 0 until 4) {
Curve25519Field.sel25519(p[i], q[i], b)
}
}
private fun packPoint(p: Array<LongArray>): ByteArray {
val zi = Curve25519Field.inv25519(p[2])
val tx = Curve25519Field.mul(p[0], zi)
val ty = Curve25519Field.mul(p[1], zi)
val r = Curve25519Field.pack25519(ty)
r[31] = (r[31].toInt() xor (Curve25519Field.par25519(tx) shl 7)).toByte()
return r
}
private fun unpackPoint(s: ByteArray): Array<LongArray>? {
val p = newPoint()
val r = Curve25519Field.unpack25519(s)
r.copyInto(p[1])
Curve25519Field.GF1.copyInto(p[2])
val y2 = Curve25519Field.sqr(r)
val d =
Curve25519Field.gf(
0x78A3,
0x1359,
0x4DCA,
0x75EB,
0xD8AB,
0x4141,
0x0A4D,
0x0070,
0xE898,
0x7779,
0x4079,
0x8CC7,
0xFE73,
0x2B6F,
0x6CEE,
0x5203,
)
val num = Curve25519Field.sub(y2, Curve25519Field.GF1)
val den = Curve25519Field.add(Curve25519Field.mul(d, y2), Curve25519Field.GF1)
val denInv = Curve25519Field.inv25519(den)
val x2 = Curve25519Field.mul(num, denInv)
var x = Curve25519Field.pow2523(x2)
x = Curve25519Field.mul(x, x2)
val check = Curve25519Field.sub(Curve25519Field.sqr(x), x2)
val checkPacked = Curve25519Field.pack25519(check)
if (!checkPacked.all { it == 0.toByte() }) {
x = Curve25519Field.mul(x, Curve25519Field.I)
val check2 = Curve25519Field.sub(Curve25519Field.sqr(x), x2)
val check2Packed = Curve25519Field.pack25519(check2)
if (!check2Packed.all { it == 0.toByte() }) {
return null
}
} }
val y = java.math.BigInteger(1, yBE) if (Curve25519Field.par25519(x) != ((s[31].toInt() shr 7) and 1)) {
return java.security.spec.EdECPoint(xOdd, y) x = Curve25519Field.sub(Curve25519Field.GF0, x)
}
x.copyInto(p[0])
Curve25519Field.mul(p[0], p[1]).copyInto(p[3])
return p
}
private val L =
longArrayOf(
0xED,
0xD3,
0xF5,
0x5C,
0x1A,
0x63,
0x12,
0x58,
0xD6,
0x9C,
0xF7,
0xA2,
0xDE,
0xF9,
0xDE,
0x14,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0x10,
)
private fun reduce(input: ByteArray): ByteArray {
val x = LongArray(64)
for (i in 0 until 64) x[i] = input[i].toLong() and 0xFF
return modL(x)
}
private fun modL(x: LongArray): ByteArray {
for (i in 63 downTo 32) {
var carry: Long = 0
var j = i - 32
val k = i - 12
while (j < k) {
x[j] += carry - 16 * x[i] * L[j - (i - 32)]
carry = (x[j] + 128) shr 8
x[j] -= carry shl 8
j++
}
x[j] += carry
x[i] = 0
}
var carry: Long = 0
for (j in 0 until 32) {
x[j] += carry - (x[31] shr 4) * L[j]
carry = x[j] shr 8
x[j] = x[j] and 0xFF
}
for (j in 0 until 32) {
x[j] -= carry * L[j]
}
val r = ByteArray(32)
for (i in 0 until 32) {
x[i + 1] += x[i] shr 8
r[i] = (x[i] and 0xFF).toByte()
}
return r
}
private fun isCanonicalScalar(s: ByteArray): Boolean {
var borrow: Long = 0
for (i in 31 downTo 0) {
val si = s[i].toLong() and 0xFF
val li = L[i]
if (si < li + borrow) return true
if (si > li + borrow) return false
borrow = 0
}
return false
} }
} }
@@ -20,32 +20,22 @@
*/ */
package com.vitorpamplona.quartz.marmot.mls.crypto package com.vitorpamplona.quartz.marmot.mls.crypto
import java.security.KeyFactory import com.vitorpamplona.quartz.utils.RandomInstance
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. * JVM/Android X25519 implementation using pure Kotlin field arithmetic.
* *
* Requires Java 11+ or Android API 31+. * This implementation is used to avoid issues with Android's KeyStore provider,
* which requires platform-specific initialization that breaks standard JCA usage.
* *
* Key format: raw 32-byte Curve25519 keys (little-endian per RFC 7748). * Key format: raw 32-byte Curve25519 keys (little-endian per RFC 7748).
*/ */
actual object X25519 { actual object X25519 {
private const val ALGORITHM = "X25519"
private const val KEY_LENGTH = 32 private const val KEY_LENGTH = 32
actual fun generateKeyPair(): X25519KeyPair { actual fun generateKeyPair(): X25519KeyPair {
val kpg = KeyPairGenerator.getInstance("XDH") val privateKey = RandomInstance.bytes(KEY_LENGTH)
kpg.initialize(NamedParameterSpec(ALGORITHM)) val publicKey = publicFromPrivate(privateKey)
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) return X25519KeyPair(privateKey, publicKey)
} }
@@ -56,116 +46,82 @@ actual object X25519 {
require(privateKey.size == KEY_LENGTH) { "Private key must be 32 bytes" } require(privateKey.size == KEY_LENGTH) { "Private key must be 32 bytes" }
require(publicKey.size == KEY_LENGTH) { "Public key must be 32 bytes" } require(publicKey.size == KEY_LENGTH) { "Public key must be 32 bytes" }
val kf = KeyFactory.getInstance("XDH") val result = scalarmult(privateKey, publicKey)
// Build JCA private key from raw bytes require(!result.all { it == 0.toByte() }) {
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()
// Check for small-subgroup attack (RFC 9180 Section 4.1)
require(!secret.all { it == 0.toByte() }) {
"DH produced all-zero shared secret (possible small-subgroup attack)" "DH produced all-zero shared secret (possible small-subgroup attack)"
} }
// XDH returns big-endian, X25519 shared secret is 32 bytes return result
// 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 { actual fun publicFromPrivate(privateKey: ByteArray): ByteArray {
require(privateKey.size == KEY_LENGTH) { "Private key must be 32 bytes" } 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) val basepoint = ByteArray(KEY_LENGTH)
basepoint[0] = 9 basepoint[0] = 9
return scalarmult(privateKey, basepoint)
return dh(privateKey, basepoint)
} }
/** /**
* Extract 32-byte raw X25519 public key from JCA XECPublicKey. * X25519 scalar multiplication via Montgomery ladder (RFC 7748).
* The u-coordinate is stored as a BigInteger, we convert to little-endian bytes. *
* Computes [n]P on Curve25519 in Montgomery form.
* Based on the TweetNaCl algorithm by Bernstein et al.
*/ */
private fun extractPublicKeyBytes(pubKey: java.security.interfaces.XECPublicKey): ByteArray { private fun scalarmult(
val u = pubKey.u n: ByteArray,
return bigIntegerToBytes(u) p: ByteArray,
} ): ByteArray {
val z = n.copyOf()
// Clamp scalar per RFC 7748 Section 5
z[0] = (z[0].toInt() and 248).toByte()
z[31] = ((z[31].toInt() and 127) or 64).toByte()
/** val x = Curve25519Field.unpack25519(p)
* Extract raw scalar bytes from JCA XECPrivateKey. val a = Curve25519Field.GF1.copyOf()
* Scalar is in little-endian byte order per JCA spec. val b = x.copyOf()
*/ val c = Curve25519Field.GF0.copyOf()
private fun extractPrivateKeyBytes(privKey: java.security.interfaces.XECPrivateKey): ByteArray { val d = Curve25519Field.GF1.copyOf()
val scalar = privKey.scalar.orElseThrow { IllegalStateException("No scalar in private key") }
return if (scalar.size == KEY_LENGTH) { for (i in 254 downTo 0) {
scalar val r = ((z[i shr 3].toLong() shr (i and 7)) and 1)
} else if (scalar.size < KEY_LENGTH) { Curve25519Field.sel25519(a, b, r)
// Pad with trailing zeros (little-endian: high-order bytes at end) Curve25519Field.sel25519(c, d, r)
val result = ByteArray(KEY_LENGTH)
scalar.copyInto(result, 0) val e = Curve25519Field.add(a, c)
result val aMc = Curve25519Field.sub(a, c)
} else { val f = Curve25519Field.add(b, d)
// Take only the first KEY_LENGTH bytes (little-endian low-order bytes) val bMd = Curve25519Field.sub(b, d)
scalar.copyOfRange(0, KEY_LENGTH)
val dd = Curve25519Field.sqr(e)
val ff = Curve25519Field.sqr(aMc)
val da = Curve25519Field.mul(bMd, e)
val cb = Curve25519Field.mul(f, aMc)
val ePrime = Curve25519Field.add(da, cb)
val aPrime = Curve25519Field.sub(da, cb)
val bNew = Curve25519Field.sqr(ePrime)
val aSqr = Curve25519Field.sqr(aPrime)
val dNew = Curve25519Field.mul(aSqr, x)
val aNew = Curve25519Field.mul(dd, ff)
val cc = Curve25519Field.sub(dd, ff)
val tmp = Curve25519Field.mul(cc, Curve25519Field.A24)
val ddPlusTmp = Curve25519Field.add(dd, tmp)
val cNew = Curve25519Field.mul(cc, ddPlusTmp)
aNew.copyInto(a)
bNew.copyInto(b)
cNew.copyInto(c)
dNew.copyInto(d)
Curve25519Field.sel25519(a, b, r)
Curve25519Field.sel25519(c, d, r)
} }
}
/** val invC = Curve25519Field.inv25519(c)
* Convert little-endian 32-byte X25519 key to BigInteger for JCA. val result = Curve25519Field.mul(a, invC)
* RFC 7748 uses little-endian, JCA uses BigInteger (unsigned). return Curve25519Field.pack25519(result)
*/
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 a leading zero sign byte.
// Reverse into little-endian, taking at most KEY_LENGTH bytes from the low end.
val bytesToCopy = minOf(beBytes.size, KEY_LENGTH)
for (i in 0 until bytesToCopy) {
result[i] = beBytes[beBytes.size - 1 - i]
}
return result
} }
} }
@@ -153,7 +153,7 @@ class MlsConformanceTest {
// Must contain external_pub extension (type 0x0003) // Must contain external_pub extension (type 0x0003)
val externalPubExt = decoded.extensions.find { it.extensionType == 0x0003 } val externalPubExt = decoded.extensions.find { it.extensionType == 0x0003 }
assertTrue(externalPubExt != null, "GroupInfo must contain external_pub extension") assertTrue(externalPubExt != null, "GroupInfo must contain external_pub extension")
assertEquals(32, externalPubExt!!.extensionData.size, "external_pub must be 32 bytes (X25519)") assertEquals(32, externalPubExt.extensionData.size, "external_pub must be 32 bytes (X25519)")
} }
@Test @Test
@@ -410,7 +410,7 @@ class MlsConformanceTest {
assertTrue(result.commitBytes.isNotEmpty(), "Commit bytes must not be empty") assertTrue(result.commitBytes.isNotEmpty(), "Commit bytes must not be empty")
assertTrue(result.welcomeBytes != null, "Add commit must produce Welcome") assertTrue(result.welcomeBytes != null, "Add commit must produce Welcome")
assertTrue(result.welcomeBytes!!.isNotEmpty(), "Welcome bytes must not be empty") assertTrue(result.welcomeBytes.isNotEmpty(), "Welcome bytes must not be empty")
// Commit should be deserializable // Commit should be deserializable
val commit = val commit =
@@ -78,7 +78,7 @@ class MlsGroupLifecycleTest {
assertEquals(2, alice.memberCount) assertEquals(2, alice.memberCount)
// Bob processes the Welcome to join the group // Bob processes the Welcome to join the group
val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) val bob = MlsGroup.processWelcome(result.welcomeBytes, bobBundle)
assertEquals(1L, bob.epoch, "Bob should be at same epoch as Alice after Welcome") assertEquals(1L, bob.epoch, "Bob should be at same epoch as Alice after Welcome")
assertEquals(2, bob.memberCount, "Bob should see 2 members") assertEquals(2, bob.memberCount, "Bob should see 2 members")
} }
@@ -173,7 +173,7 @@ class MlsGroupTest {
assertTrue(result.commitBytes.isNotEmpty(), "Commit bytes should not be empty") assertTrue(result.commitBytes.isNotEmpty(), "Commit bytes should not be empty")
assertNotNull(result.welcomeBytes, "Welcome bytes should be present for Add") assertNotNull(result.welcomeBytes, "Welcome bytes should be present for Add")
assertTrue(result.welcomeBytes!!.isNotEmpty(), "Welcome bytes should not be empty") assertTrue(result.welcomeBytes.isNotEmpty(), "Welcome bytes should not be empty")
// After commit, epoch should advance // After commit, epoch should advance
assertEquals(1L, aliceGroup.epoch) assertEquals(1L, aliceGroup.epoch)