Kotlin: X25519, with the small-order check that is not optional here
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bff7a4bbdd
commit
85f6a29953
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IllegalArgumentException> { X25519.publicKeyFromRaw(ByteArray(31)) }
|
||||||
|
assertFailsWith<IllegalArgumentException> { X25519.publicKeyFromRaw(ByteArray(33)) }
|
||||||
|
assertFailsWith<IllegalArgumentException> { 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user