diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt new file mode 100644 index 0000000..a0f1084 --- /dev/null +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt @@ -0,0 +1,166 @@ +package io.flamenet.obfs4 + +import org.bouncycastle.crypto.engines.XSalsa20Engine +import org.bouncycastle.crypto.macs.Poly1305 +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.params.ParametersWithIV + +/** + * NaCl `crypto_secretbox` — XSalsa20-Poly1305 — and obfs4's link framing. + * + * ⚠️ **XSalsa20 and Poly1305 both come from BouncyCastle here.** The Swift port + * hand-writes both because CryptoKit's AEADs are AES-GCM and ChaChaPoly and it + * exposes neither primitive. Poly1305 in particular is a MAC, so a bug in it is + * a forgery rather than a wrong answer — taking a vetted implementation is the + * right call wherever one exists. + */ +object Secretbox { + + const val OVERHEAD = 16 + + /** + * 🔴 **The first 32 bytes of the keystream are the Poly1305 key, and the + * message starts at stream offset 32.** Encrypting from offset 0 produces a + * ciphertext that decrypts perfectly against itself and against nothing + * else. Prefixing 32 zero bytes and slicing is the same computation written + * so the offset cannot be forgotten. + */ + fun seal(message: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + require(nonce.size == 24 && key.size == 32) + val stream = keystream(ByteArray(32) + message, nonce, key) + val polyKey = stream.copyOfRange(0, 32) + val ciphertext = stream.copyOfRange(32, stream.size) + return mac(polyKey, ciphertext) + ciphertext + } + + /** `null` on a tag mismatch — which is fatal, never retried. */ + fun open(box: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray? { + require(nonce.size == 24 && key.size == 32) + if (box.size < OVERHEAD) return null + val tag = box.copyOfRange(0, OVERHEAD) + val ciphertext = box.copyOfRange(OVERHEAD, box.size) + + val stream = keystream(ByteArray(32) + ciphertext, nonce, key) + val polyKey = stream.copyOfRange(0, 32) + + // 🔴 Authenticate BEFORE returning any plaintext. The stream is computed + // either way, but nothing derived from it may escape until the tag + // checks out. + if (!constantTimeEquals(tag, mac(polyKey, ciphertext))) return null + return stream.copyOfRange(32, stream.size) + } + + private fun keystream(input: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + val engine = XSalsa20Engine() + engine.init(true, ParametersWithIV(KeyParameter(key), nonce)) + return ByteArray(input.size).also { engine.processBytes(input, 0, input.size, it, 0) } + } + + private fun mac(key: ByteArray, message: ByteArray): ByteArray { + val poly = Poly1305() + poly.init(KeyParameter(key)) + poly.update(message, 0, message.size) + return ByteArray(16).also { poly.doFinal(it, 0) } + } + + fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } +} + +/** + * obfs4 link framing: a two-byte obfuscated length followed by a secretbox. + * + * 🔴 **The nonce is never transmitted.** It is a fixed 16-byte prefix from the + * key plus a big-endian 64-bit counter that **starts at 1** and increments per + * frame. Both sides track it independently, so a frame processed out of order, + * twice, or not at all desynchronises the connection permanently — and the + * symptom is a Poly1305 failure on the *next* frame, not the one at fault. + * + * ⚠️ Starting the counter at 0 interoperates with nothing, and is also how the + * wraparound check works: the reference treats 0 as "wrapped" precisely because + * it can never legitimately be 0. + */ +class FrameEncoder(key: ByteArray) { + private val boxKey = key.copyOfRange(0, 32) + private val noncePrefix = key.copyOfRange(32, 48) + private val drbg = LengthDrbg(key.copyOfRange(48, 72)) + private var counter = 1L + + init { require(key.size == KEY_LENGTH) { "the framing key is $KEY_LENGTH bytes" } } + + class PayloadTooLong(val length: Int) : Exception("payload too long: $length") + class NonceCounterWrapped : Exception("the nonce counter wrapped — fatal") + + fun encode(payload: ByteArray): ByteArray { + if (payload.size > MAX_PAYLOAD_LENGTH) throw PayloadTooLong(payload.size) + if (counter == 0L) throw NonceCounterWrapped() + + val nonce = noncePrefix + ByteArray(8) { ((counter ushr (8 * (7 - it))) and 0xff).toByte() } + counter++ + + val box = Secretbox.seal(payload, nonce, boxKey) + val masked = (box.size xor drbg.nextLengthMask()) and 0xffff + return byteArrayOf((masked shr 8).toByte(), masked.toByte()) + box + } + + companion object { + const val KEY_LENGTH = 72 // 32 box key ‖ 16 nonce prefix ‖ 24 DRBG seed + const val OVERHEAD = 2 + Secretbox.OVERHEAD + const val MAX_SEGMENT_LENGTH = 1448 + const val MAX_PAYLOAD_LENGTH = MAX_SEGMENT_LENGTH - OVERHEAD + } +} + +/** + * The other half. Separate because the two directions of a connection use + * different keys and must never share DRBG or counter state. + */ +class FrameDecoder(key: ByteArray) { + private val boxKey = key.copyOfRange(0, 32) + private val noncePrefix = key.copyOfRange(32, 48) + private val drbg = LengthDrbg(key.copyOfRange(48, 72)) + private var counter = 1L + private var pendingLength: Int? = null + + init { require(key.size == FrameEncoder.KEY_LENGTH) } + + class NeedMoreData : Exception("more data needed") + class TagMismatch : Exception("Poly1305 tag mismatch — fatal") + class InvalidLength(val length: Int) : Exception("invalid frame length: $length") + + /** + * Consumes one whole frame from `buffer`, removing it. + * + * ⚠️ **The length must be unmasked exactly once per frame.** The DRBG + * advances when read, so peeking at the length twice — say, on a short read + * that is retried — desynchronises the stream. The unmasked length is + * therefore remembered until its frame is consumed. + */ + fun decode(buffer: MutableList): ByteArray { + if (pendingLength == null) { + if (buffer.size < 2) throw NeedMoreData() + val masked = ((buffer[0].toInt() and 0xff) shl 8) or (buffer[1].toInt() and 0xff) + buffer.subList(0, 2).clear() + val length = masked xor drbg.nextLengthMask() + if (length < Secretbox.OVERHEAD || length > FrameEncoder.MAX_SEGMENT_LENGTH - 2) { + throw InvalidLength(length) + } + pendingLength = length + } + val length = pendingLength!! + if (buffer.size < length) throw NeedMoreData() + + val nonce = noncePrefix + ByteArray(8) { ((counter ushr (8 * (7 - it))) and 0xff).toByte() } + val box = ByteArray(length) { buffer[it] } + val payload = Secretbox.open(box, nonce, boxKey) ?: throw TagMismatch() + + buffer.subList(0, length).clear() + counter++ + pendingLength = null + return payload + } +} diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt new file mode 100644 index 0000000..6770c96 --- /dev/null +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt @@ -0,0 +1,217 @@ +package io.flamenet.obfs4 + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The framing layer, against lyrebird. + * + * ⚠️ Standalone vectors for XSalsa20 and Poly1305 sit beside the whole-frame + * ones deliberately: without them a failing secretbox says only "one of these + * two is wrong" and the next hour goes into finding out which. + */ +class FramingTest { + + private fun vectors(): JSONObject { + val stream = javaClass.classLoader.getResourceAsStream("framing-vectors.json") + assertNotNull(stream, "the framing vectors are missing") + return JSONObject(stream.readBytes().decodeToString()) + } + + private fun hex(s: String) = ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } + + /** + * ⚠️ **This tests our CALL, not BouncyCastle's arithmetic.** Poly1305 is + * vetted; what is unproven is whether the key and parameters are being + * handed over correctly, which is exactly where a wrapper goes wrong. + * Lengths straddle the 16-byte block, where the final partial block has its + * own padding rule. + */ + @Test + fun `poly1305 matches the reference`() { + val v = vectors().getJSONArray("poly1305") + val mismatches = mutableListOf() + for (i in 0 until v.length()) { + val o = v.getJSONObject(i) + val poly = org.bouncycastle.crypto.macs.Poly1305() + poly.init(org.bouncycastle.crypto.params.KeyParameter(hex(o.getString("key")))) + val msg = hex(o.getString("message")) + poly.update(msg, 0, msg.size) + val tag = ByteArray(16).also { poly.doFinal(it, 0) } + if (hex(tag) != o.getString("tag")) mismatches += "len ${msg.size}" + } + assertTrue(mismatches.isEmpty(), + "${mismatches.size} Poly1305 tags wrong (${mismatches.joinToString()})") + } + + /** Same rationale: this pins how Salsa20 is invoked, not that it is correct. */ + @Test + fun `salsa20 keystream matches the reference`() { + val v = vectors().getJSONArray("salsa20") + val mismatches = mutableListOf() + for (i in 0 until v.length()) { + val o = v.getJSONObject(i) + val want = hex(o.getString("keystream")) + val engine = org.bouncycastle.crypto.engines.Salsa20Engine() + engine.init(true, org.bouncycastle.crypto.params.ParametersWithIV( + org.bouncycastle.crypto.params.KeyParameter(hex(o.getString("key"))), + hex(o.getString("nonce")))) + val got = ByteArray(want.size) + engine.processBytes(ByteArray(want.size), 0, want.size, got, 0) + if (!got.contentEquals(want)) mismatches += "len ${want.size}" + } + assertTrue(mismatches.isEmpty(), + "${mismatches.size} Salsa20 keystreams wrong (${mismatches.joinToString()})") + } + + @Test + fun `secretbox matches the reference`() { + val v = vectors().getJSONArray("secretbox") + val mismatches = mutableListOf() + for (i in 0 until v.length()) { + val o = v.getJSONObject(i) + val got = Secretbox.seal(hex(o.getString("plaintext")), + hex(o.getString("nonce")), hex(o.getString("key"))) + if (hex(got) != o.getString("box")) { + mismatches += "len ${o.getString("plaintext").length / 2}" + } + } + assertTrue(mismatches.isEmpty(), + "${mismatches.size} secretboxes wrong (${mismatches.joinToString()}). " + + "If the primitives pass on their own, the fault is the 32-byte " + + "stream offset or the HSalsa20 subkey.") + } + + @Test + fun `secretbox opens what it seals`() { + val v = vectors().getJSONArray("secretbox") + for (i in 0 until v.length()) { + val o = v.getJSONObject(i) + assertEquals(o.getString("plaintext"), + hex(Secretbox.open(hex(o.getString("box")), hex(o.getString("nonce")), + hex(o.getString("key")))!!)) + } + } + + /** 🔴 A tampered box must not open. This is the property the layer exists for. */ + @Test + fun `secretbox rejects tampering`() { + val v = vectors().getJSONArray("secretbox") + var o: JSONObject? = null + for (i in 0 until v.length()) { + if (v.getJSONObject(i).getString("plaintext").isNotEmpty()) { o = v.getJSONObject(i); break } + } + assertNotNull(o) + for (pos in listOf(0, 8, 15, 16, 17)) { + val box = hex(o.getString("box")) + if (pos >= box.size) continue + box[pos] = (box[pos].toInt() xor 1).toByte() + assertNull(Secretbox.open(box, hex(o.getString("nonce")), hex(o.getString("key"))), + "a box with byte $pos flipped must not open") + } + val nonce = hex(o.getString("nonce")).also { it[0] = (it[0].toInt() xor 1).toByte() } + assertNull(Secretbox.open(hex(o.getString("box")), nonce, hex(o.getString("key"))), + "the wrong nonce must not open the box") + } + + /** + * 🔴 **A run of frames, not one frame.** The nonce counter and the DRBG both + * advance per frame, so a single-frame test passes for an implementation + * whose counter never increments and whose length mask is recomputed from + * scratch every time. + */ + @Test + fun `frame runs match lyrebird byte for byte`() { + val runs = vectors().getJSONArray("frames") + assertTrue(runs.getJSONObject(0).getJSONArray("frames").length() > 3, + "too few frames per run") + + val mismatches = mutableListOf() + for (i in 0 until runs.length()) { + val run = runs.getJSONObject(i) + val enc = FrameEncoder(hex(run.getString("key"))) + val payloads = run.getJSONArray("payloads") + val frames = run.getJSONArray("frames") + for (j in 0 until payloads.length()) { + val got = enc.encode(hex(payloads.getString(j))) + if (hex(got) != frames.getString(j)) { + mismatches += "run ${run.getString("key").take(12)}… frame $j" + break + } + } + } + assertTrue(mismatches.isEmpty(), + "${mismatches.size} of ${runs.length()} runs diverged. A failure at frame 0 " + + "is the secretbox or the first length mask; at frame 1+ it is the nonce " + + "counter or the DRBG advance.\n" + mismatches.take(2).joinToString("\n")) + } + + @Test + fun `the decoder reads lyrebirds frames`() { + val runs = vectors().getJSONArray("frames") + for (i in 0 until runs.length()) { + val run = runs.getJSONObject(i) + val dec = FrameDecoder(hex(run.getString("key"))) + val buffer = mutableListOf() + val frames = run.getJSONArray("frames") + for (j in 0 until frames.length()) buffer.addAll(hex(frames.getString(j)).toList()) + val payloads = run.getJSONArray("payloads") + for (j in 0 until payloads.length()) { + assertEquals(payloads.getString(j), hex(dec.decode(buffer)), "frame $j") + } + assertTrue(buffer.isEmpty(), "bytes left over after the last frame") + } + } + + /** + * ⚠️ Real sockets deliver frames in pieces. One byte at a time must give the + * same result — and must not advance the DRBG twice on a length read across + * two reads. + */ + @Test + fun `decoding survives byte-at-a-time delivery`() { + val run = vectors().getJSONArray("frames").getJSONObject(0) + val dec = FrameDecoder(hex(run.getString("key"))) + val wire = mutableListOf() + val frames = run.getJSONArray("frames") + for (j in 0 until frames.length()) wire.addAll(hex(frames.getString(j)).toList()) + + val buffer = mutableListOf() + val decoded = mutableListOf() + for (b in wire) { + buffer.add(b) + while (true) { + try { decoded.add(hex(dec.decode(buffer))) } + catch (e: FrameDecoder.NeedMoreData) { break } + } + } + val payloads = run.getJSONArray("payloads") + assertEquals((0 until payloads.length()).map { payloads.getString(it) }, decoded) + } + + @Test + fun `tampered frames are rejected`() { + val run = vectors().getJSONArray("frames").getJSONObject(0) + val dec = FrameDecoder(hex(run.getString("key"))) + val buffer = hex(run.getJSONArray("frames").getString(0)).toMutableList() + buffer[buffer.size - 1] = (buffer[buffer.size - 1].toInt() xor 1).toByte() + assertFailsWith { dec.decode(buffer) } + } + + @Test + fun `oversized payloads are refused and the boundary is accepted`() { + val enc = FrameEncoder(ByteArray(FrameEncoder.KEY_LENGTH) { 7 }) + assertFailsWith { + enc.encode(ByteArray(FrameEncoder.MAX_PAYLOAD_LENGTH + 1)) + } + enc.encode(ByteArray(FrameEncoder.MAX_PAYLOAD_LENGTH)) + } +}