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:
co-authored by
Claude Opus 5
parent
430f167fc0
commit
799886f35c
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user