From 85f6a29953482ceb026d9dea9a3b19cb25269468 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 22 Aug 2026 11:42:01 -0400 Subject: [PATCH] Kotlin: X25519, with the small-order check that is not optional here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw 32-byte keys on the wire, X.509 SubjectPublicKeyInfo in the JCA -- so every crossing needs the 12-byte DER prefix added or stripped. Written out as a constant rather than parsed: a DER parser here would be more code than the thing it parses, and one more place to get a length wrong. Refuses an all-zero shared secret. X25519 produces all zeros for a small-order peer key, and a peer that sends one forces both sides to a secret it already knows. RFC 7748 says implementations MAY check; when the peer's key arrives over a relay nobody trusts, MAY is not the right reading. All four small-order points from §6.1 are tested. ⚠️ XDH is API 33+ on Android. Older devices throw NoSuchAlgorithmException at the first call, and the app must either bundle a provider or refuse to run -- a decision nobody has made, so it is documented rather than papered over. ⚠️ These run on the JVM against SunEC. Android routes the same calls through Conscrypt. Passing here says the encoding is right and nothing about a device. Kotlin engine: 27 tests -> 33. Co-Authored-By: Claude Opus 5 --- .../src/main/kotlin/io/flamenet/e2e/X25519.kt | 83 ++++++++++++++++ .../test/kotlin/io/flamenet/e2e/X25519Test.kt | 98 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 engine/src/main/kotlin/io/flamenet/e2e/X25519.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt diff --git a/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt b/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt new file mode 100644 index 0000000..ec3d513 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt @@ -0,0 +1,83 @@ +package io.flamenet.e2e + +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.spec.X509EncodedKeySpec +import javax.crypto.KeyAgreement + +/** + * X25519 Diffie-Hellman. + * + * The protocol moves raw 32-byte public keys — that is what the wire carries, + * what CryptoKit's `rawRepresentation` gives, and what the JavaScript engine + * exports. The JVM and Android both insist on X.509 `SubjectPublicKeyInfo` + * instead, so every crossing of that boundary needs the DER wrapper added or + * stripped. + * + * ⚠️ **API 33+ on Android.** `XDH` arrived in the platform with Android 13. On + * anything older this throws `NoSuchAlgorithmException` at the first call, and + * the app has to either bundle a provider or refuse to run — a decision that + * has not been made yet, so it is documented rather than papered over. + * + * ⚠️ **These tests run on the JVM, where the provider is SunEC. Android routes + * the same calls through Conscrypt.** Passing here says the encoding is right; + * it says nothing about the device. + */ +object X25519 { + + const val KEY_LEN = 32 + + /** + * The fixed prefix of an X.509-wrapped X25519 public key: SEQUENCE, the + * id-X25519 OID (1.3.101.110), then a 32-bit BIT STRING. + * + * Written out rather than parsed because it is a constant twelve bytes and + * a DER parser here would be more code than the thing it parses — and one + * more place to get a length wrong. + */ + private val SPKI_PREFIX = byteArrayOf( + 0x30, 0x2a, // SEQUENCE, 42 bytes + 0x30, 0x05, // SEQUENCE, 5 bytes + 0x06, 0x03, 0x2b, 0x65, 0x6e, // OID 1.3.101.110 + 0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused + ) + + fun generate(): KeyPair = KeyPairGenerator.getInstance("XDH").apply { initialize(255) }.generateKeyPair() + + /** Wrap a raw 32-byte public key so the JCA will accept it. */ + fun publicKeyFromRaw(raw: ByteArray): java.security.PublicKey { + require(raw.size == KEY_LEN) { "an X25519 public key is $KEY_LEN bytes, got ${raw.size}" } + return KeyFactory.getInstance("XDH") + .generatePublic(X509EncodedKeySpec(SPKI_PREFIX + raw)) + } + + /** The raw 32 bytes the wire carries, from a JCA public key. */ + fun rawFromPublicKey(key: java.security.PublicKey): ByteArray { + val encoded = key.encoded + require(encoded.size == SPKI_PREFIX.size + KEY_LEN) { + "unexpected X25519 SPKI length ${encoded.size}" + } + return encoded.copyOfRange(SPKI_PREFIX.size, encoded.size) + } + + /** + * The shared secret. + * + * **Rejects an all-zero result.** X25519 produces all zeros for a + * small-order public key, and a peer that sends one can force both sides to + * a secret it already knows. RFC 7748 says implementations MAY check; for a + * key-agreement protocol where the peer's key arrives over an untrusted + * relay, it is not optional. + */ + fun agree(privateKey: java.security.PrivateKey, peerRaw: ByteArray): ByteArray { + val agreement = KeyAgreement.getInstance("XDH") + agreement.init(privateKey) + agreement.doPhase(publicKeyFromRaw(peerRaw), true) + val secret = agreement.generateSecret() + if (secret.all { it.toInt() == 0 }) { + throw SecurityException("X25519 produced an all-zero secret: the peer key has small order") + } + return secret + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt b/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt new file mode 100644 index 0000000..19a0aaa --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt @@ -0,0 +1,98 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * X25519 against RFC 7748's published vectors, plus the raw/DER boundary the + * wire depends on. + */ +class X25519Test { + + private fun hex(s: String): ByteArray = + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + + /** + * RFC 7748 §6.1, the worked Diffie-Hellman example. + * + * Only the public halves are checked here: the JCA will not construct an + * X25519 private key from raw scalar bytes without a PKCS#8 wrapper, and + * building one by hand to check a number the RFC already publishes would be + * testing the wrapper rather than the agreement. The agreement itself is + * checked by the symmetry test below, which is the property that matters. + */ + @Test + fun `rfc 7748 public keys survive the raw to DER round trip`() { + val alicePub = hex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a") + val bobPub = hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") + for (raw in listOf(alicePub, bobPub)) { + val key = X25519.publicKeyFromRaw(raw) + assertContentEquals(raw, X25519.rawFromPublicKey(key), + "raw -> DER -> raw must be the identity") + } + } + + /** The property the protocol actually rests on. */ + @Test + fun `both sides derive the same secret`() { + val alice = X25519.generate() + val bob = X25519.generate() + val fromAlice = X25519.agree(alice.private, X25519.rawFromPublicKey(bob.public)) + val fromBob = X25519.agree(bob.private, X25519.rawFromPublicKey(alice.public)) + assertContentEquals(fromAlice, fromBob) + assertEquals(32, fromAlice.size) + } + + @Test + fun `a different peer gives a different secret`() { + val alice = X25519.generate() + val bob = X25519.generate() + val eve = X25519.generate() + val withBob = X25519.agree(alice.private, X25519.rawFromPublicKey(bob.public)) + val withEve = X25519.agree(alice.private, X25519.rawFromPublicKey(eve.public)) + assertTrue(!withBob.contentEquals(withEve)) + } + + @Test + fun `public keys are exactly 32 bytes on the wire`() { + assertEquals(32, X25519.rawFromPublicKey(X25519.generate().public).size) + } + + @Test + fun `a wrong-sized public key is refused rather than padded`() { + assertFailsWith { X25519.publicKeyFromRaw(ByteArray(31)) } + assertFailsWith { X25519.publicKeyFromRaw(ByteArray(33)) } + assertFailsWith { X25519.publicKeyFromRaw(ByteArray(0)) } + } + + /** + * RFC 7748 §6.1 lists the small-order points. A peer sending one forces the + * shared secret to all zeros — which both sides would then happily derive + * keys from, and the sender knows it in advance. + * + * The RFC says implementations MAY check. When the peer's key arrives over + * a relay nobody trusts, that is not optional. + */ + @Test + fun `small-order peer keys are refused, not silently accepted`() { + val alice = X25519.generate() + val smallOrder = listOf( + "0000000000000000000000000000000000000000000000000000000000000000", + "0100000000000000000000000000000000000000000000000000000000000000", + "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800", + "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157", + ) + for (point in smallOrder) { + val thrown = runCatching { X25519.agree(alice.private, hex(point)) }.exceptionOrNull() + assertTrue( + thrown != null, + "small-order point $point produced a secret instead of being refused", + ) + } + } +}