Commit Graph
8 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 11f0fe9fee Count handshakes at the bridge, so the socket-factory test proves something
This fix was made when the iOS end-to-end wiring test was found passing
while BYPASSED, and the same flaw was confirmed here — but it never got
committed, so the assertion has been sitting in a working tree while the
merged test went on proving nothing.

The test fetches `http://127.0.0.1:<upstream>/probe`. That URL points at
the upstream directly, so a request that ignored the socket factory
entirely reaches the same server and returns the same body. Asserting on
the response proved the HTTP server worked; it said nothing at all about
whether obfs4 was in the path.

The bridge now counts sessions that completed a handshake, and the test
asserts the count rose. Removing the socket factory reddens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:11:53 -04:00
Paul HittandClaude Opus 5 dd412b8680 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>
2026-09-06 13:39:24 -04:00
Paul HittandClaude Opus 5 a7ce010403 obfs4 for Android, part 6: live interop with real lyrebird, both directions
scripts/obfs4-interop.sh builds a real lyrebird bridge and a real
lyrebird client from the flamenet-elligator2 checkout and runs the Kotlin
port against both. The bridge accepts the Kotlin client's handshake and
echoes data; lyrebird's own client accepts the Kotlin bridge and its
echo. 61 tests.

Both directions are needed, and the falsification shows why: swapping the
framing key halves by role leaves the CLIENT test green -- a client takes
the first half either way -- and fails only the reverse one. Encoding the
epoch hour as binary rather than a decimal string fails both. The
probe-resistance test, which asserts the bridge stays silent for garbage,
passes throughout, as it should.

Both clients are pinned to the SAME reference rather than to each other.
Two independent implementations agreeing with lyrebird is a real
statement; agreeing with each other is not, and would hide anything they
share.

The runner's name check earned its keep twice in ten minutes. First it
reported failure while all three tests had passed, because Gradle prints
`name() PASSED` with parentheses and the pattern omitted them. Then, with
that fixed, it caught the tests not running AT ALL: Gradle's up-to-date
check does not know the Go counterparty changed, so a second run skips
the task silently. Hence --rerun-tasks. An exit code alone would have
called both of those green.

The tests never skip quietly: absent configuration is a hard failure
unless OBFS4_INTEROP_OPTOUT=1 is set explicitly. Verified both ways --
the bare run fails, the opted-out run passes 61.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:33:44 -04:00
Paul HittandClaude Opus 5 ac5802a839 obfs4 for Android, part 5: handshake messages, packets, and the connection
The protocol is now complete in Kotlin, short of a socket. 58 tests.

Handshake messages are checked against RECORDINGS, not computed values:
the vectors are real lyrebird-to-lyrebird handshakes captured off a pipe,
because the message carries random padding chosen inside lyrebird and a
MAC over the current epoch hour. The padding turns out to be recoverable
-- it sits between a fixed-length prefix and the mark -- so rebuilding
from the recovered representative, padding and hour must reproduce the
recording byte for byte. That makes these a test of generation, not only
of parsing, and both directions pass.

The epoch hour is a parameter, never read from the clock inside the
library, because a MAC with a hidden clock dependency cannot be tested
against a recording.

The asymmetry that catches people is preserved: the server searches only
the TAIL for the mark, because a client cannot send anything valid past
its MAC; the client must SUBSTRING-search, because a real server sends
framed data immediately after its handshake. The symmetric reading fails
against every real bridge.

GoRandom reproduces Go's math/rand quirks exactly -- float64 dividing an
int63 by 2^63 rather than the cleaner form Go rejected, and perm's
useless first swap -- so the packet-length distribution derived from a
seed matches lyrebird's. That buys indistinguishability, NOT
interoperability: padding is padding, and any distribution talks to a
real bridge. What a different one costs is looking like other obfs4
traffic, which is the entire point.

The client handshake returns leftover BYTES rather than an offset,
carrying forward the fix the Swift port needed: an offset indexes a
buffer accumulated across all calls, which a caller feeding one socket
read at a time does not have, and it only misfires when the server
handshake spans two reads -- routine over a network, almost never over
loopback.

HONEST GAP: the connection layer here is self-consistency-tested only.
Everything below it is checked against lyrebird's bytes, but two
identically-wrong ends agree perfectly. The Swift port has a live interop
test against a real lyrebird bridge; this one does not yet, and that is
the next thing that matters -- more than the OkHttp seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:26:53 -04:00
Paul HittandClaude Opus 5 799886f35c obfs4 for Android, part 4: framing
Whole runs of frames now match lyrebird byte for byte and decode back
byte-at-a-time. 33 tests.

XSalsa20 and Poly1305 both come from BouncyCastle. The Swift port
hand-writes both because CryptoKit's AEADs are AES-GCM and ChaChaPoly and
it exposes neither primitive; Poly1305 especially is a MAC, so a bug in
it is a forgery rather than a wrong answer, and taking a vetted
implementation is the right call wherever one exists.

Added standalone Poly1305 and Salsa20 vector tests, because the comment
above them claimed those vectors sat beside the frame ones and nothing
was using them. They do not test BouncyCastle's arithmetic -- that is
vetted -- they test that it is being CALLED correctly, key and parameter
setup being exactly where a wrapper goes wrong.

The frame tests run a sequence, not a single frame: the nonce counter and
the DRBG both advance per frame, so a one-frame test passes for an
implementation whose counter never increments and whose length mask is
recomputed from scratch each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:16:09 -04:00
Paul HittandClaude Opus 5 430f167fc0 obfs4 for Android, part 3: SipHash, the length DRBG, and ntor
64/64 handshakes agree with lyrebird on KEY_SEED, AUTH and KDF output,
from both the client and the server side. 23 tests.

SipHash is the one primitive hand-written here despite BouncyCastle
shipping one, and that is worth justifying because everything else in
this module is BouncyCastle's. BC's SipHash is a Mac: doFinal RESETS.
obfs4's DRBG writes eight bytes, finalises, writes eight more and
finalises again -- without ever resetting. Emulating that with a
resetting MAC means re-hashing the whole history per block, O(n^2) in the
frames on a connection. So the state machine is written out, and `sum`
finalises a COPY.

The DRBG is checked ten blocks deep, not one. obfs4's comment says each
block is a fresh H(K, previous); the code never resets, so blocks are
cumulative. The two readings agree on block one and diverge afterwards --
an implementation built from the comment decodes the first frame of a
connection perfectly and then fails.

ntor carries the same trap the Swift port hit: the transcript is
B | B | X | Y | PROTOID | ID, with B twice, while the comment directly
above that code claims ID | B | Y | X | PROTOID. Both orderings produce a
well-formed 32-byte KEY_SEED. Only one interoperates.

HMAC via javax.crypto, HKDF via BouncyCastle's HKDFBytesGenerator, X25519
via the ladder from part 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:13:02 -04:00
Paul HittandClaude Opus 5 44e06adf92 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>
2026-09-06 13:06:42 -04:00
Paul HittandClaude Opus 5 db35c7ce55 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>
2026-09-06 12:46:25 -04:00