obfs4 for Android, part 1: Elligator2, against the same vectors as Swift

A new `:obfs4` module, a plain JVM library like `:engine` so its tests run
without an emulator -- which matters here because the only tests that mean
anything are vector comparisons against lyrebird.

Far smaller than the Swift port, for a reason worth recording: the Swift
implementation hand-writes SipHash-2-4, XSalsa20, Poly1305 and its own
field arithmetic because CryptoKit exposes none of them. The JVM does not
have that hole. BouncyCastle -- already a dependency of :engine, for the
same class of reason -- has X25519Field, XSalsa20Engine, Poly1305 and
SipHash, vetted and constant-time where it matters.

So the two clients do NOT share an implementation. They share the vectors
and the live interop harness, which is what actually keeps them honest:
two independent implementations agreeing with the same reference is a
stronger statement than one agreeing with itself twice.

Two things BouncyCastle would not do, both found by vectors rather than
by reading.

X25519Field.sqrtRatioVar is not a drop-in. Elligator needs the root even
when the ratio is NOT square -- it is sqrt(i*u/v) then, and the algorithm
uses it to pick a branch rather than to detect an error. BC only promises
a usable root in the square case, and handing its output to the map
produced zeros for 120 of 256 vectors. The exponent chain and branch
selection are written out here; the field arithmetic underneath is still
BouncyCastle's.

X25519Field.cmov did nothing at all with a condition of 1. Both tweak
parities produced the SAME representative -- a map that still
round-trips, still looks uniform, and only ever emits one of the two
valid answers. The failing assertion said "wrong bytes"; it took a
diagnostic printing both candidates to see they were identical. Replaced
with an explicit mask so the convention is stated rather than assumed.

Behind no setting yet because nothing calls it: this is the crypto
foundation only. The transport, the OkHttp socket factory and the
user-facing switch come next, and the switch defaults to off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-06 12:46:25 -04:00
co-authored by Claude Opus 5
parent 945978972a
commit db35c7ce55
6 changed files with 4355 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
plugins {
kotlin("jvm")
jacoco
}
repositories { mavenCentral() }
dependencies {
// 🔴 **The whole reason this port is small.** The Swift implementation had
// to hand-write SipHash-2-4, XSalsa20 and Poly1305 because CryptoKit
// exposes none of them, and its own field arithmetic because Apple gives
// nothing below X25519. BouncyCastle has all four, vetted and
// constant-time where it matters: `X25519Field` (including `sqrtRatioVar`,
// exactly the primitive Elligator2 needs), `XSalsa20Engine`, `Poly1305`.
//
// ⚠️ That means the two clients do NOT share an implementation. They share
// the vectors and the live interop harness, which is what actually keeps
// them honest — see `Obfs4VectorTest`.
//
// Already a dependency of `:engine`, for the same class of reason: taking a
// vetted constant-time implementation over hand-rolling arithmetic on a
// secret scalar.
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
testImplementation(kotlin("test"))
testImplementation("org.json:json:20240303")
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.test {
useJUnitPlatform()
testLogging { events("passed", "failed", "skipped"); showStandardStreams = true }
}
tasks.withType<Test>().configureEach { finalizedBy(tasks.named("jacocoTestReport")) }
tasks.named<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.named("test"))
reports { xml.required.set(true); html.required.set(true) }
}