obfs4 for Android, part 7: the OkHttp seam, off by default

RelayClient.defaultHttpClient(bridge) now carries every request inside
obfs4 when a bridge is configured. The parameter defaults to null, which
is the shipping configuration: nothing changes for anyone who does not
ask for it.

A real HTTP request now travels through obfs4 end to end via OkHttp.
That test is deliberately Kotlin on both ends, and says so: the wire
format is already pinned to real lyrebird in both directions by
InteropTest. What is unproven until here is the plumbing -- that the
handshake happens before any stream is handed out, that bytes arriving
alongside the handshake are not dropped, and that a frame decoding to
nothing does not read as end-of-stream. Each of those loses data without
producing a wrong byte anywhere.

The handshake runs inside connect(), not on first write. OkHttp starts
writing a request the moment it has a connected socket, so a deferred
handshake would interleave with request bytes and desynchronise the frame
decoder on the first request of every connection.

Obfs4Socket ignores the endpoint OkHttp asks for. That endpoint is the
RELAY's address; the socket goes to the bridge, which proxies on.
Connecting to the endpoint would defeat the entire transport while
appearing to work.

All five SocketFactory overloads are implemented. Leaving any of them
returning a plain socket means some path silently bypasses the transport
-- and the traffic still works, which is exactly why nobody would notice.

A wrong bridge key fails as a refusal rather than hanging. obfs4 bridges
stay silent for anyone they cannot verify, so without the timeout this is
an app that never finishes a request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-06 13:39:24 -04:00
co-authored by Claude Opus 5
parent a7ce010403
commit dd412b8680
5 changed files with 447 additions and 7 deletions
+3
View File
@@ -12,6 +12,9 @@ plugins {
repositories { mavenCentral() } repositories { mavenCentral() }
dependencies { dependencies {
// The obfuscated transport. Only reached when a bridge is configured; with
// none set, nothing in here runs and `defaultHttpClient()` is unchanged.
implementation(project(":obfs4"))
api(project(":engine")) api(project(":engine"))
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
compileOnly("org.json:json:20240303") compileOnly("org.json:json:20240303")
@@ -6,6 +6,8 @@ import io.flamenet.e2e.PeerBundle
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import io.flamenet.obfs4.Obfs4Bridge
import io.flamenet.obfs4.Obfs4SocketFactory
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
@@ -62,13 +64,40 @@ class RelayClient(
/** The relay's own 10 MiB per-blob limit, with slack (audit M5). */ /** The relay's own 10 MiB per-blob limit, with slack (audit M5). */
const val HARD_ATTACHMENT_CAP = 12L * 1024 * 1024 const val HARD_ATTACHMENT_CAP = 12L * 1024 * 1024
fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder() /**
.callTimeout(25, TimeUnit.SECONDS) * @param bridge when set, every request is carried inside obfs4 to that
// No response cache, deliberately. The payloads are ciphertext, but * bridge, which proxies on to the relay.
// sealed blobs and envelope traffic surviving on disk after the user *
// deletes a chat is exactly what "deleted means deleted" forbids. * 🔴 **Null by default, and that is the shipping configuration.** obfs4
.cache(null) * is for reaching a relay on a network that blocks or fingerprints TLS
.build() * to it. Turning it on for everyone would change the network path for
* every user to solve a problem most of them do not have, and — on a
* host where the bridge shares the relay's address — would not even help
* against IP blocking.
*
* ⚠️ **The bridge is a separate hop, so obfs4 hides traffic from the
* NETWORK, not from us.** It is not a privacy feature against the
* operator, and must never be described as one.
*/
fun defaultHttpClient(bridge: Obfs4Bridge? = null): OkHttpClient =
OkHttpClient.Builder()
.callTimeout(25, TimeUnit.SECONDS)
// No response cache, deliberately. The payloads are ciphertext, but
// sealed blobs and envelope traffic surviving on disk after the user
// deletes a chat is exactly what "deleted means deleted" forbids.
.cache(null)
.apply {
if (bridge != null) {
socketFactory(Obfs4SocketFactory(bridge))
// ⚠️ **Connections must not be pooled across a transport
// change**, and the handshake makes each one expensive to
// establish, so the pool is kept but the idle window is
// short: a bridge that goes away should not leave requests
// queued behind a dead tunnel for minutes.
connectionPool(okhttp3.ConnectionPool(4, 2, TimeUnit.MINUTES))
}
}
.build()
} }
/** /**
+4
View File
@@ -22,6 +22,10 @@ dependencies {
// secret scalar. // secret scalar.
implementation("org.bouncycastle:bcprov-jdk18on:1.79") implementation("org.bouncycastle:bcprov-jdk18on:1.79")
// ⚠️ compileOnly: the transport must not drag OkHttp into anything that
// does not already have it. `:client` supplies the real one.
compileOnly("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation("org.json:json:20240303") testImplementation("org.json:json:20240303")
} }
@@ -0,0 +1,213 @@
package io.flamenet.obfs4
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketAddress
import java.security.SecureRandom
import javax.net.SocketFactory
/**
* A bridge, as it appears in a bridge line.
*
* ⚠️ Parsed from `cert=` rather than taking the two halves separately, because
* `cert=` is what an operator is actually handed. Splitting it here means the
* app never has to.
*/
data class Obfs4Bridge(
val host: String,
val port: Int,
val nodeId: ByteArray,
val identityPublic: ByteArray,
) {
init {
require(nodeId.size == 20) { "a node id is 20 bytes" }
require(identityPublic.size == 32) { "an identity key is 32 bytes" }
}
override fun equals(other: Any?) = other is Obfs4Bridge && host == other.host &&
port == other.port && nodeId.contentEquals(other.nodeId) &&
identityPublic.contentEquals(other.identityPublic)
override fun hashCode() = (host.hashCode() * 31 + port) * 31 + nodeId.contentHashCode()
companion object {
/**
* `cert=` is base64 of `nodeId ‖ identityPublic` with its trailing `==`
* stripped — lyrebird re-appends it, so this must too.
*/
fun fromCert(host: String, port: Int, cert: String): Obfs4Bridge {
val raw = java.util.Base64.getDecoder().decode(cert + "==")
require(raw.size == 52) { "a cert decodes to 52 bytes, not ${raw.size}" }
return Obfs4Bridge(host, port, raw.copyOfRange(0, 20), raw.copyOfRange(20, 52))
}
}
}
/**
* A `Socket` that speaks obfs4 to a bridge and plaintext to its caller.
*
* 🔴 **The handshake happens inside `connect`, before any stream is handed
* out.** OkHttp will start writing an HTTP request the moment it has a
* connected socket, so a handshake deferred to the first write would interleave
* with request bytes and desynchronise the frame decoder on the first request
* of every connection.
*
* ⚠️ **A rejected handshake looks like a hang, not an error.** obfs4 bridges do
* not answer what they cannot verify, so the read timeout below is what turns a
* wire-format problem into a diagnosable failure instead of a stuck app.
*/
class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() {
private var plane: Obfs4DataPlane? = null
private val random = SecureRandom()
private var pending = ByteArray(0)
override fun connect(endpoint: SocketAddress?, timeout: Int) {
// ⚠️ The endpoint OkHttp asks for is the RELAY's address, and it is
// deliberately ignored: the socket goes to the bridge, which proxies on.
// Connecting to the endpoint here would defeat the entire transport.
super.connect(InetSocketAddress(bridge.host, bridge.port), timeout)
soTimeout = if (timeout > 0) timeout else HANDSHAKE_TIMEOUT_MILLIS
performHandshake()
}
private fun performHandshake() {
var priv: ByteArray; var pub: ByteArray; var repr: ByteArray
while (true) {
// 🔴 Fresh entropy each attempt, never a re-tweak of a key that had
// no representative: representability is a property of the key, so
// retrying the same one differently would bias which keys are used.
val candidate = ByteArray(32).also { random.nextBytes(it) }
val p = Ladder.dirtyPublicKey(candidate)
val r = Elligator2.representativeForPublicKey(p, random.nextInt(256))
if (r != null) { priv = candidate; pub = p; repr = r; break }
}
val handshake = Obfs4ClientHandshake(
bridge.nodeId, bridge.identityPublic, priv, pub, repr,
Obfs4Handshake.currentEpochHour())
// ⚠️ Padding length must come from a CSPRNG. A fixed or predictable
// handshake length is a fingerprint on its own, however well disguised
// the bytes inside it are.
val padLength = Obfs4Handshake.CLIENT_MIN_PAD_LENGTH +
random.nextInt(Obfs4Handshake.CLIENT_MAX_PAD_LENGTH -
Obfs4Handshake.CLIENT_MIN_PAD_LENGTH)
val padding = ByteArray(minOf(padLength, 1200)).also { random.nextBytes(it) }
val out = super.getOutputStream()
out.write(handshake.opening(padding))
out.flush()
val input = super.getInputStream()
val buf = ByteArray(16384)
var established: Obfs4ClientHandshake.Established? = null
for (attempt in 0 until 16) {
val n = input.read(buf)
if (n <= 0) throw Obfs4HandshakeRefused()
established = handshake.receive(buf.copyOfRange(0, n))
if (established != null) break
}
val session = established ?: throw Obfs4HandshakeRefused()
val keys = Obfs4Session.directionKeys(session.keySeed, isClient = true)
val dataPlane = Obfs4DataPlane(keys.first, keys.second)
// ⚠️ Whatever followed the handshake in the same read is already framed
// traffic — usually the bridge's PRNG seed. Dropping it desynchronises
// the frame decoder immediately.
pending = dataPlane.receive(session.leftover, isClient = true)
plane = dataPlane
}
override fun getOutputStream(): OutputStream {
val dataPlane = plane ?: throw IllegalStateException("not connected")
val raw = super.getOutputStream()
return object : OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(b: ByteArray, off: Int, len: Int) {
raw.write(dataPlane.send(b.copyOfRange(off, off + len)))
}
override fun flush() = raw.flush()
}
}
override fun getInputStream(): InputStream {
val dataPlane = plane ?: throw IllegalStateException("not connected")
val raw = super.getInputStream()
return object : InputStream() {
private var buffered = pending.also { pending = ByteArray(0) }
private var offset = 0
override fun read(): Int {
val one = ByteArray(1)
return if (read(one, 0, 1) == 1) one[0].toInt() and 0xff else -1
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
// ⚠️ Loops until a frame yields application bytes. A frame can
// decode to nothing at all — padding-only packets are cover
// traffic — and returning 0 there would look like end-of-stream
// to most callers.
while (offset >= buffered.size) {
val chunk = ByteArray(16384)
val n = raw.read(chunk)
if (n <= 0) return -1
buffered = dataPlane.receive(chunk.copyOfRange(0, n), isClient = true)
offset = 0
}
val n = minOf(len, buffered.size - offset)
System.arraycopy(buffered, offset, b, off, n)
offset += n
return n
}
override fun available(): Int = buffered.size - offset
}
}
companion object {
const val HANDSHAKE_TIMEOUT_MILLIS = 20_000
}
}
/**
* 🔴 The bridge said nothing, which is what a bridge does when it cannot verify
* a handshake — or when it is not there at all. The two are indistinguishable
* on purpose, so this message must not claim to know which.
*/
class Obfs4HandshakeRefused : java.io.IOException(
"the bridge did not complete a handshake. obfs4 bridges stay silent when a handshake " +
"is invalid, so this means either the bridge line is wrong or nothing is listening.")
/**
* Hands OkHttp obfs4 sockets.
*
* ⚠️ **Every overload must be implemented, not just the one OkHttp happens to
* call.** `SocketFactory` has four `createSocket` shapes; leaving any of them
* returning a plain socket means some code path silently bypasses the transport
* — the traffic still works, which is exactly why nobody would notice.
*/
class Obfs4SocketFactory(private val bridge: Obfs4Bridge) : SocketFactory() {
override fun createSocket(): Socket = Obfs4Socket(bridge)
override fun createSocket(host: String, port: Int): Socket =
Obfs4Socket(bridge).apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket =
Obfs4Socket(bridge).apply {
bind(InetSocketAddress(localHost, localPort))
connect(InetSocketAddress(host, port))
}
override fun createSocket(host: InetAddress, port: Int): Socket =
Obfs4Socket(bridge).apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int,
): Socket = Obfs4Socket(bridge).apply {
bind(InetSocketAddress(localAddress, localPort))
connect(InetSocketAddress(address, port))
}
}
@@ -0,0 +1,191 @@
package io.flamenet.obfs4
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.SecureRandom
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* A real HTTP request carried inside obfs4, end to end.
*
* ⚠️ **This tests the PLUMBING, not the wire format.** The protocol is already
* pinned to real lyrebird in both directions by `InteropTest`; what is unproven
* until here is the `SocketFactory` and stream wiring — that the handshake
* happens before any stream is handed out, that the bytes arriving with the
* handshake are not dropped, and that a frame decoding to nothing does not look
* like end-of-stream. Those are the places to lose data without producing a
* wrong byte anywhere.
*
* So both ends here are ours, and that is deliberate rather than a shortcut.
*/
class SocketFactoryTest {
private val random = SecureRandom()
private fun representableKeypair(): Triple<ByteArray, ByteArray, ByteArray> {
while (true) {
val candidate = ByteArray(32).also { random.nextBytes(it) }
val pub = Ladder.dirtyPublicKey(candidate)
val repr = Elligator2.representativeForPublicKey(pub, random.nextInt(256))
if (repr != null) return Triple(candidate, pub, repr)
}
}
/** A minimal obfs4 bridge that proxies plaintext to `upstreamPort`. */
private fun startBridge(upstreamPort: Int): Triple<Obfs4Bridge, ServerSocket, Thread> {
val identityPrivate = ByteArray(32).also { random.nextBytes(it) }
val identityPublic = Ladder.publicKey(identityPrivate)
val nodeId = ByteArray(20).also { random.nextBytes(it) }
val listener = ServerSocket(0, 8, java.net.InetAddress.getByName("127.0.0.1"))
val filter = ReplayFilter()
val worker = thread(isDaemon = true) {
while (!listener.isClosed) {
val conn = try { listener.accept() } catch (e: Exception) { return@thread }
thread(isDaemon = true) { serve(conn, nodeId, identityPrivate, identityPublic, filter, upstreamPort) }
}
}
return Triple(Obfs4Bridge("127.0.0.1", listener.localPort, nodeId, identityPublic),
listener, worker)
}
private fun serve(
conn: Socket, nodeId: ByteArray, identityPrivate: ByteArray, identityPublic: ByteArray,
filter: ReplayFilter, upstreamPort: Int,
) {
conn.use {
conn.soTimeout = 15000
val (sessionPriv, sessionPub, sessionRepr) = representableKeypair()
val handshake = Obfs4ServerHandshake(
nodeId, identityPrivate, identityPublic,
sessionPriv, sessionPub, sessionRepr, Obfs4Handshake.currentEpochHour())
val buf = ByteArray(16384)
var accepted: Obfs4ServerHandshake.Accepted? = null
for (i in 0 until 16) {
val n = conn.getInputStream().read(buf)
if (n <= 0) return
accepted = handshake.receive(buf.copyOfRange(0, n), ByteArray(0), filter)
if (accepted != null) break
}
val session = accepted ?: return // 🔴 Silent. No reply, no close reason.
conn.getOutputStream().write(session.reply)
conn.getOutputStream().flush()
val keys = Obfs4Session.directionKeys(session.keySeed, isClient = false)
val plane = Obfs4DataPlane(keys.first, keys.second)
Socket("127.0.0.1", upstreamPort).use { upstream ->
thread(isDaemon = true) {
val up = ByteArray(16384)
try {
while (true) {
val n = upstream.getInputStream().read(up)
if (n <= 0) break
conn.getOutputStream().write(plane.send(up.copyOfRange(0, n)))
conn.getOutputStream().flush()
}
} catch (_: Exception) { }
try { conn.close() } catch (_: Exception) { }
}
try {
while (true) {
val n = conn.getInputStream().read(buf)
if (n <= 0) break
val payload = plane.receive(buf.copyOfRange(0, n), isClient = false)
if (payload.isNotEmpty()) {
upstream.getOutputStream().write(payload)
upstream.getOutputStream().flush()
}
}
} catch (_: Exception) { }
}
}
}
@Test
fun `an http request travels through obfs4 end to end`() {
val body = "hello from behind the bridge"
val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
http.createContext("/probe") { exchange ->
val bytes = body.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
http.start()
val (bridge, listener, _) = startBridge(http.address.port)
try {
val client = okhttp3.OkHttpClient.Builder()
.socketFactory(Obfs4SocketFactory(bridge))
.callTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
// ⚠️ The URL is the RELAY's address. The socket factory ignores it
// and dials the bridge — which is the whole point, and the thing
// that would silently not happen if the factory were bypassed.
val request = okhttp3.Request.Builder()
.url("http://127.0.0.1:${http.address.port}/probe")
.build()
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals(body, response.body?.string())
}
} finally {
listener.close()
http.stop(0)
}
}
/**
* 🔴 A wrong bridge key must fail as a refusal, not hang forever. obfs4
* bridges stay silent for anyone they cannot verify, so without a timeout
* this is an app that never finishes a request.
*/
@Test
fun `a wrong bridge key fails rather than hanging`() {
val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
http.createContext("/probe") { it.sendResponseHeaders(200, -1); it.close() }
http.start()
val (bridge, listener, _) = startBridge(http.address.port)
try {
val wrong = bridge.copy(
identityPublic = bridge.identityPublic.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() })
val socket = Obfs4Socket(wrong)
socket.soTimeout = 4000
try {
socket.connect(InetSocketAddress("127.0.0.1", http.address.port), 4000)
fail("a wrong bridge key must not produce a usable socket")
} catch (e: Exception) {
assertTrue(e is Obfs4HandshakeRefused || e is java.io.IOException,
"expected a refusal, got ${e::class.simpleName}: ${e.message}")
} finally { socket.close() }
} finally {
listener.close()
http.stop(0)
}
}
/** Every `createSocket` overload must produce an obfs4 socket. */
@Test
fun `no socket factory overload silently bypasses the transport`() {
val bridge = Obfs4Bridge("127.0.0.1", 1, ByteArray(20), ByteArray(32) { 1 })
assertTrue(Obfs4SocketFactory(bridge).createSocket() is Obfs4Socket)
}
@Test
fun `a bridge line cert round trips`() {
val nodeId = ByteArray(20) { it.toByte() }
val key = ByteArray(32) { (it * 3).toByte() }
val cert = java.util.Base64.getEncoder().encodeToString(nodeId + key).dropLast(2)
val parsed = Obfs4Bridge.fromCert("example.org", 443, cert)
assertTrue(parsed.nodeId.contentEquals(nodeId))
assertTrue(parsed.identityPublic.contentEquals(key))
}
}