diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt new file mode 100644 index 0000000..395fd29 --- /dev/null +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt @@ -0,0 +1,190 @@ +package io.flamenet.obfs4 + +import org.bouncycastle.math.ec.rfc7748.X25519Field + +/** + * X25519 scalar multiplication, including the **cofactor-dirty** variant obfs4 + * uses for key generation. + * + * 🔴 **The dirty variant is why obfs4 public keys differ from every normal + * X25519 implementation's.** A conventional implementation clamps the scalar, + * clearing the cofactor and confining the result to the prime-order subgroup — + * and a point known to be in that subgroup is *distinguishable*, which undoes + * the whole point of Elligator2. obfs4 keeps the cofactor component so the + * public key ranges over the full curve. + * + * ⚠️ **BouncyCastle's `X25519.scalarMult` cannot be used for it.** That clamps + * internally and runs 255 bits; the dirty variant needs the *combined* scalar + * fed to a **256-bit** ladder with no re-clamping. The field arithmetic here is + * still BouncyCastle's — only the ladder and the scalar handling are written + * out. + * + * Follows Monocypher's `crypto_x25519_dirty_small`, which decouples the + * cofactor from the main factor and multiplies a single combined scalar by a + * base point of order 8·L. lyrebird instead uses the Edwards route; the two + * agree, and the vectors check that rather than taking it on faith. + */ +object Ladder { + + /** + * A base point of order 8·L, so multiplying by it leaves the cofactor + * intact. + * + * ⚠️ Its low-order component is not arbitrary — it was chosen so this route + * lands on the same public key as the Edwards route obfs4 actually uses. + * Any other order-8 point gives a self-consistent implementation that no + * longer interoperates. + */ + private val DIRTY_BASE_POINT = byteArrayOf( + 0xd8.toByte(), 0x86.toByte(), 0x1a, 0xa2.toByte(), 0x78, 0x7a, 0xd9.toByte(), 0x26, + 0x8b.toByte(), 0x74, 0x74, 0xb6.toByte(), 0x82.toByte(), 0xe3.toByte(), 0xbe.toByte(), 0xc3.toByte(), + 0xce.toByte(), 0x36, 0x9a.toByte(), 0x1e, 0x5e, 0x31, 0x47, 0xa2.toByte(), + 0x6d, 0x37, 0x7c, 0xfd.toByte(), 0x20, 0xb5.toByte(), 0xdf.toByte(), 0x75, + ) + + private val BASE_POINT_9 = ByteArray(32).also { it[0] = 9 } + + /** L, the order of the prime-order subgroup, as eight 32-bit words. */ + private val GROUP_ORDER = longArrayOf( + 0x5cf5d3edL, 0x5812631aL, 0xa2f79cd6L, 0x14def9deL, + 0x00000000L, 0x00000000L, 0x00000000L, 0x10000000L, + ) + + /** The standard X25519 clamp. */ + fun trimScalar(s: ByteArray): ByteArray = s.copyOf().also { + it[0] = (it[0].toInt() and 248).toByte() + it[31] = (it[31].toInt() and 127).toByte() + it[31] = (it[31].toInt() or 64).toByte() + } + + /** + * Add `(x and 7)·L` to a 256-bit little-endian scalar. + * + * 🔴 **This is the step that puts the cofactor back.** Trimming cleared the + * three low bits, but those bits still carry a main-factor component that + * has to be removed for X25519 compatibility; adding `lsb·L` (mod 8·L) + * recombines the two into one scalar. Drop it and you get an ordinary clean + * public key — which works, agrees on shared secrets, and is exactly the + * distinguishable thing obfs4 avoids. + */ + fun addGroupOrderTimes(x: Int, s: ByteArray): ByteArray { + val out = s.copyOf() + val mod8 = (x and 7).toLong() + var carry = 0L + for (i in 0 until 8) { + var word = 0L + for (j in 3 downTo 0) word = (word shl 8) or (out[4 * i + j].toLong() and 0xff) + carry += word + GROUP_ORDER[i] * mod8 + for (j in 0 until 4) out[4 * i + j] = ((carry ushr (8 * j)) and 0xff).toByte() + carry = carry ushr 32 + } + return out + } + + /** `bit == 1` swaps `a` and `b`, without branching on the bit. */ + private fun cswap(bit: Int, a: IntArray, b: IntArray) { + // ⚠️ An explicit mask, deliberately. `X25519Field.cmov` was found to do + // nothing at all when handed a plain `1`, so nothing in this file + // assumes BouncyCastle's conditional helpers take a boolean-shaped + // condition. See Elligator2.cselect. + val mask = -(bit and 1) + for (i in a.indices) { + val t = mask and (a[i] xor b[i]) + a[i] = a[i] xor t + b[i] = b[i] xor t + } + } + + /** + * The Montgomery ladder in projective coordinates, ref10's, via Monocypher. + * + * ⚠️ **`bits` is 256 for the dirty variant, not the usual 255.** The + * combined scalar can exceed 2^255 once `lsb·L` has been added, so a ladder + * that stops at bit 254 silently drops the top bit and returns a wrong + * point for a fraction of keys — a bug any small sample would miss. + */ + fun ladder(scalar: ByteArray, base: ByteArray, bits: Int): ByteArray { + val x1 = X25519Field.create().also { X25519Field.decode(base, 0, it) } + val x2 = X25519Field.create().also { X25519Field.one(it) } + val z2 = X25519Field.create() + val x3 = x1.copyOf() + val z3 = X25519Field.create().also { X25519Field.one(it) } + + val t0 = X25519Field.create() + val t1 = X25519Field.create() + val t2 = X25519Field.create() + + var swap = 0 + for (pos in bits - 1 downTo 0) { + val bit = (scalar[pos shr 3].toInt() shr (pos and 7)) and 1 + swap = swap xor bit + cswap(swap, x2, x3) + cswap(swap, z2, z3) + swap = bit // the xor trick: one swap deferred + + X25519Field.sub(x3, z3, t0) + X25519Field.sub(x2, z2, t1) + X25519Field.add(x2, z2, x2) + X25519Field.add(x3, z3, z2) + X25519Field.mul(t0, x2, z3) + X25519Field.mul(z2, t1, z2) + X25519Field.sqr(t1, t0) + X25519Field.sqr(x2, t1) + X25519Field.add(z3, z2, x3) + X25519Field.sub(z3, z2, z2) + X25519Field.mul(t1, t0, x2) + X25519Field.sub(t1, t0, t1) + X25519Field.sqr(z2, z2) + X25519Field.mul(t1, 121666, z3) + X25519Field.sqr(x3, x3) + X25519Field.add(t0, z3, t2) + X25519Field.mul(x1, z2, z3) + X25519Field.mul(t1, t2, z2) + } + // ⚠️ Compensates for the xor trick. Omitting it gives a result that is + // right for exactly the scalars whose bottom bit happens to agree. + cswap(swap, x2, x3) + cswap(swap, z2, z3) + + val inv = X25519Field.create().also { X25519Field.inv(z2, it) } + val out = X25519Field.create().also { X25519Field.mul(x2, inv, it) } + X25519Field.normalize(out) + return ByteArray(32).also { X25519Field.encode(out, it, 0) } + } + + /** + * A private key to its **cofactor-dirty** X25519 public key. + * + * The private key must be the full 32 bytes of entropy. Pre-clamping it + * yields non-uniformly distributed representatives, which is the whole + * failure this module exists to avoid. + */ + fun dirtyPublicKey(privateKey: ByteArray): ByteArray { + require(privateKey.size == 32) { "a private key is 32 bytes" } + val combined = addGroupOrderTimes(privateKey[0].toInt(), trimScalar(privateKey)) + return ladder(combined, DIRTY_BASE_POINT, 256) + } + + /** + * The ordinary, cofactor-cleared public key. + * + * ⚠️ **A bridge's long-term identity key is CLEAN.** Only the ephemeral + * session keys keep their cofactor, because only those travel as + * representatives. (A dirty identity key does in fact still complete a + * handshake — X25519 clamps, so the order-8 component cancels on both + * sides — but every other implementation and every bridge-line tool expects + * the clean one.) + */ + fun publicKey(privateKey: ByteArray): ByteArray = + ladder(trimScalar(privateKey), BASE_POINT_9, 255) + + /** + * Ordinary X25519. + * + * 🔴 The result must be checked for all-zeros by the caller: a peer that + * sends a low-order point drives every shared secret to zero, which a + * handshake has to reject rather than key from. + */ + fun sharedSecret(privateKey: ByteArray, peerPublicKey: ByteArray): ByteArray = + ladder(trimScalar(privateKey), peerPublicKey, 255) +} diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt new file mode 100644 index 0000000..953c31f --- /dev/null +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt @@ -0,0 +1,128 @@ +package io.flamenet.obfs4 + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Cofactor-dirty scalar base multiplication, against lyrebird. + * + * 🔴 **The wrong answer sits right next to the right one.** The clean X25519 + * public key — what every library computes — is a perfectly good Curve25519 + * key. It agrees on shared secrets. It round-trips. It is simply confined to + * the prime-order subgroup and therefore *distinguishable*, which defeats the + * entire purpose. Clamping the private key before the call is all it takes to + * get one, so this checks structure as well as bytes. + */ +class LadderTest { + + private fun vectors(): JSONObject { + val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json") + assertNotNull(stream, "the vectors are missing from test resources") + return JSONObject(stream.readBytes().decodeToString()) + } + + private fun hex(s: String) = ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } + + /** + * ⚠️ The `private` field is the 32 bytes lyrebird actually fed to + * `ScalarBaseMult` — already past its SHA-512 pass, and *not* clamped. + * Clamping before this call would clear the cofactor and quietly turn this + * into the clean function. + */ + @Test + fun `the dirty public key matches lyrebird`() { + val keygen = vectors().getJSONArray("keygen") + assertTrue(keygen.length() > 100, "too few vectors to mean anything") + + val mismatches = mutableListOf() + for (i in 0 until keygen.length()) { + val v = keygen.getJSONObject(i) + val got = Ladder.dirtyPublicKey(hex(v.getString("private"))) + if (hex(got) != v.getString("public")) { + mismatches += "private ${v.getString("private")}\n" + + " want ${v.getString("public")}\n got ${hex(got)}" + } + } + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} of ${keygen.length()} private keys produced the wrong " + + "public key.\n\n${mismatches.take(2).joinToString("\n")}", + ) + } + + /** + * 🔴 The structural check. If the cofactor were being cleared this would be + * the clean key, so it must differ from the clean key exactly when the low + * three bits of the private key are non-zero, and match it exactly when + * they are not. + */ + @Test + fun `it differs from the clean key exactly when the cofactor is set`() { + val keygen = vectors().getJSONArray("keygen") + val wrong = mutableListOf() + var withCofactor = 0 + var without = 0 + + for (i in 0 until keygen.length()) { + val priv = hex(keygen.getJSONObject(i).getString("private")) + val dirty = Ladder.dirtyPublicKey(priv) + val clean = Ladder.publicKey(priv) + if ((priv[0].toInt() and 7) != 0) { + withCofactor++ + if (dirty.contentEquals(clean)) wrong += "cofactor set but the key is clean" + } else { + without++ + if (!dirty.contentEquals(clean)) wrong += "no cofactor but the keys differ" + } + } + assertTrue(wrong.isEmpty(), "${wrong.size} keys had the wrong relationship to the clean key") + // ⚠️ Both arms must actually occur, or the assertion above is vacuous + // for one of them. + assertTrue(withCofactor > 10, "no keys with a cofactor component") + assertTrue(without > 10, "no keys without a cofactor component") + } + + @Test + fun `the whole keypair matches lyrebird end to end`() { + val keygen = vectors().getJSONArray("keygen") + var mismatches = 0 + for (i in 0 until keygen.length()) { + val v = keygen.getJSONObject(i) + val pub = Ladder.dirtyPublicKey(hex(v.getString("private"))) + val want = hex(v.getString("representative")) + .also { it[31] = (it[31].toInt() and 0x3f).toByte() } + val matched = listOf(0, 1).any { tweak -> + Elligator2.representativeForPublicKey(pub, tweak) + ?.also { it[31] = (it[31].toInt() and 0x3f).toByte() } + ?.contentEquals(want) == true + } + if (!matched || hex(pub) != v.getString("public")) mismatches++ + } + assertEquals(0, mismatches, "$mismatches keypairs did not match lyrebird end to end") + } + + @Test + fun `trimScalar clamps the standard bits`() { + val all = ByteArray(32) { 0xff.toByte() } + val t = Ladder.trimScalar(all) + assertEquals(0xf8, t[0].toInt() and 0xff, "the low three bits must be cleared") + assertEquals(0x7f, t[31].toInt() and 0xff, "bit 255 cleared and bit 254 set") + assertEquals(0x40, Ladder.trimScalar(ByteArray(32))[31].toInt() and 0xff) + } + + @Test + fun `adding the group order is driven by the low three bits`() { + val s = ByteArray(32) { 0x11 } + assertTrue(Ladder.addGroupOrderTimes(0xf8, s).contentEquals(s), + "low three bits clear must leave the scalar alone") + for (x in 1..7) { + assertTrue(!Ladder.addGroupOrderTimes(x, s).contentEquals(s), "x=$x must change it") + } + } +}