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:
co-authored by
Claude Opus 5
parent
a7ce010403
commit
dd412b8680
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user