diff --git a/client/build.gradle.kts b/client/build.gradle.kts index d791939..66e2b28 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -12,6 +12,9 @@ plugins { repositories { mavenCentral() } 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")) implementation("com.squareup.okhttp3:okhttp:4.12.0") compileOnly("org.json:json:20240303") diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index ab8547f..1d275c0 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -6,6 +6,8 @@ import io.flamenet.e2e.PeerBundle import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType +import io.flamenet.obfs4.Obfs4Bridge +import io.flamenet.obfs4.Obfs4SocketFactory import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody @@ -62,13 +64,40 @@ class RelayClient( /** The relay's own 10 MiB per-blob limit, with slack (audit M5). */ const val HARD_ATTACHMENT_CAP = 12L * 1024 * 1024 - fun defaultHttpClient(): 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) - .build() + /** + * @param bridge when set, every request is carried inside obfs4 to that + * bridge, which proxies on to the relay. + * + * 🔴 **Null by default, and that is the shipping configuration.** obfs4 + * is for reaching a relay on a network that blocks or fingerprints TLS + * 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() } /** diff --git a/obfs4/build.gradle.kts b/obfs4/build.gradle.kts index 073ae80..c5aa391 100644 --- a/obfs4/build.gradle.kts +++ b/obfs4/build.gradle.kts @@ -22,6 +22,10 @@ dependencies { // secret scalar. 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("org.json:json:20240303") } diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt new file mode 100644 index 0000000..ae705e3 --- /dev/null +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt @@ -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)) + } +} diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt new file mode 100644 index 0000000..9fc8a01 --- /dev/null +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt @@ -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 { + 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 { + 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)) + } +}