obfs4 for Android, part 2: the cofactor-dirty scalar multiplication
256/256 private keys now produce lyrebird's exact public key, and the whole keypair -- private key to public key and representative -- matches end to end. 13 tests. BouncyCastle's X25519.scalarMult cannot be used for this: it clamps internally and runs 255 bits, while the dirty variant needs the COMBINED scalar fed to a 256-bit ladder with no re-clamping. The combined scalar can exceed 2^255 once lsb*L is added, so a 255-bit ladder silently drops the top bit for a fraction of keys. Only the ladder and the scalar handling are written out; the field arithmetic underneath is still BouncyCastle's. The danger here is that the wrong answer looks entirely fine. A clean X25519 public key agrees on shared secrets and round-trips; it is just confined to the prime-order subgroup and therefore distinguishable, and clamping the private key before the call is all it takes to get one. So there is a structural test beside the byte comparison: the dirty key must differ from the clean key exactly when the low three bits of the private key are non-zero, with both arms asserted to actually occur so neither passes vacuously. cswap here uses an explicit mask rather than any BouncyCastle conditional. Nothing in this module assumes those take a boolean-shaped condition, after X25519Field.cmov was found to do nothing at all when handed a plain 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db35c7ce55
commit
44e06adf92
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
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<String>()
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user