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>
This commit is contained in:
Paul Hitt
2026-09-06 13:16:09 -04:00
co-authored by Claude Opus 5
parent 430f167fc0
commit 799886f35c
2 changed files with 383 additions and 0 deletions
@@ -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<Byte>): 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
}
}
@@ -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<String>()
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<String>()
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<String>()
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<String>()
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<Byte>()
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<Byte>()
val frames = run.getJSONArray("frames")
for (j in 0 until frames.length()) wire.addAll(hex(frames.getString(j)).toList())
val buffer = mutableListOf<Byte>()
val decoded = mutableListOf<String>()
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<FrameDecoder.TagMismatch> { dec.decode(buffer) }
}
@Test
fun `oversized payloads are refused and the boundary is accepted`() {
val enc = FrameEncoder(ByteArray(FrameEncoder.KEY_LENGTH) { 7 })
assertFailsWith<FrameEncoder.PayloadTooLong> {
enc.encode(ByteArray(FrameEncoder.MAX_PAYLOAD_LENGTH + 1))
}
enc.encode(ByteArray(FrameEncoder.MAX_PAYLOAD_LENGTH))
}
}