5a648f33b5ea46eeffecd1148b86c4cfc6815ba8
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6cd7a0d5dc |
A settings screen for the bridge line, and a path for it to reach
Two gaps, and the second was the bigger one. obfs4 was reachable from nothing. The socket-factory seam landed in !77, but every client in the app takes `defaultHttpClient()`'s null default — `RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, `RelayDiscovery` and the background service, ten sites in all, several from default parameters evaluated at field-initialisation time. The transport had been built, tested and proven against real lyrebird in both directions, and no user could switch it on. Threading a bridge through ten constructors would have worked until an eleventh was added, and the eleventh would connect directly, successfully, with nothing to show that it had. A missed site does not fail; it silently does not protect. So the factory is installed unconditionally and consults `Obfs4Routing` per socket. With no bridge it delegates to the platform's own factory, which is what OkHttp would have used anyway — nothing changes for the overwhelming majority who never set one. Pooled connections are the trap underneath that. OkHttp keeps a connection alive across requests, so one opened through the old bridge serves requests for minutes after the user switches — or after they turn a bridge OFF, which is the dangerous direction. The pool is shared so `Obfs4Routing.use` can empty it. The other gap is the one flagged when the wiring merged: a malformed line parses to nothing, nothing means no bridge, and no bridge is a direct connection. So `BridgeLine.parse` reports which part is wrong, and `Obfs4Settings` stores only what it accepted. Java's decoder made that worse here than on iOS. `fromCert` appended `==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51 bytes among them — so a short paste came back as unreadable gibberish while iOS, whose decoder is padding-lenient, called the same paste "cut short". That is the likeliest real mistake by a distance: bridge lines wrap in every chat app and terminal, and copying only the first line is ordinary. Decoding the bare string works at every length and lets the size check give the honest answer. Both forms produce identical bytes for a real 52-byte cert, so nothing that worked before changes. The messages are the same sentences iOS shows, pinned by a test. The two apps are handed the same lines by the same operators, and one support answer should cover both. None of them says obfs4, base64 or node id — whoever reaches this screen was handed a line and told to paste it, and an error in the protocol's vocabulary reads as "you are not qualified to fix this". `iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to space its writes and the bridge does not enforce them, so such a line connects and works. Refusing it would take a working bridge away from somebody who has no other one. The status line reads a handshake counter, never the stored preference. A saved bridge that is not there leaves the app connecting directly, and only a completed handshake can tell you otherwise — so a bridge that has carried nothing says exactly that, and the words "protected", "secure", "hidden" and "safe" are asserted absent from it. Live interop re-run against real lyrebird after the factory change: both directions still pass. 448 unit tests green, 145 in the app, and reverting the unconditional factory reddens the routing test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |