obfs4 for Android, part 1: Elligator2 #104

Merged
paulhitt merged 7 commits from feat/obfs4-kotlin-elligator2 into main 2026-09-06 18:09:37 +00:00
27 changed files with 8171 additions and 7 deletions
+3
View File
@@ -12,6 +12,9 @@ plugins {
repositories { mavenCentral() }
dependencies {
// The obfuscated transport. Only reached when a bridge is configured; with
// none set, nothing in here runs and `defaultHttpClient()` is unchanged.
implementation(project(":obfs4"))
api(project(":engine"))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
compileOnly("org.json:json:20240303")
@@ -6,6 +6,8 @@ import io.flamenet.e2e.PeerBundle
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import io.flamenet.obfs4.Obfs4Bridge
import io.flamenet.obfs4.Obfs4SocketFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
@@ -62,13 +64,40 @@ class RelayClient(
/** The relay's own 10 MiB per-blob limit, with slack (audit M5). */
const val HARD_ATTACHMENT_CAP = 12L * 1024 * 1024
fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder()
.callTimeout(25, TimeUnit.SECONDS)
// No response cache, deliberately. The payloads are ciphertext, but
// sealed blobs and envelope traffic surviving on disk after the user
// deletes a chat is exactly what "deleted means deleted" forbids.
.cache(null)
.build()
/**
* @param bridge when set, every request is carried inside obfs4 to that
* bridge, which proxies on to the relay.
*
* 🔴 **Null by default, and that is the shipping configuration.** obfs4
* is for reaching a relay on a network that blocks or fingerprints TLS
* to it. Turning it on for everyone would change the network path for
* every user to solve a problem most of them do not have, and — on a
* host where the bridge shares the relay's address — would not even help
* against IP blocking.
*
* ⚠️ **The bridge is a separate hop, so obfs4 hides traffic from the
* NETWORK, not from us.** It is not a privacy feature against the
* operator, and must never be described as one.
*/
fun defaultHttpClient(bridge: Obfs4Bridge? = null): OkHttpClient =
OkHttpClient.Builder()
.callTimeout(25, TimeUnit.SECONDS)
// No response cache, deliberately. The payloads are ciphertext, but
// sealed blobs and envelope traffic surviving on disk after the user
// deletes a chat is exactly what "deleted means deleted" forbids.
.cache(null)
.apply {
if (bridge != null) {
socketFactory(Obfs4SocketFactory(bridge))
// ⚠️ **Connections must not be pooled across a transport
// change**, and the handshake makes each one expensive to
// establish, so the pool is kept but the idle window is
// short: a bridge that goes away should not leave requests
// queued behind a dead tunnel for minutes.
connectionPool(okhttp3.ConnectionPool(4, 2, TimeUnit.MINUTES))
}
}
.build()
}
/**
+51
View File
@@ -0,0 +1,51 @@
plugins {
kotlin("jvm")
jacoco
}
repositories { mavenCentral() }
dependencies {
// 🔴 **The whole reason this port is small.** The Swift implementation had
// to hand-write SipHash-2-4, XSalsa20 and Poly1305 because CryptoKit
// exposes none of them, and its own field arithmetic because Apple gives
// nothing below X25519. BouncyCastle has all four, vetted and
// constant-time where it matters: `X25519Field` (including `sqrtRatioVar`,
// exactly the primitive Elligator2 needs), `XSalsa20Engine`, `Poly1305`.
//
// ⚠️ That means the two clients do NOT share an implementation. They share
// the vectors and the live interop harness, which is what actually keeps
// them honest — see `Obfs4VectorTest`.
//
// Already a dependency of `:engine`, for the same class of reason: taking a
// vetted constant-time implementation over hand-rolling arithmetic on a
// secret scalar.
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
// ⚠️ compileOnly: the transport must not drag OkHttp into anything that
// does not already have it. `:client` supplies the real one.
compileOnly("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation(kotlin("test"))
testImplementation("org.json:json:20240303")
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.test {
useJUnitPlatform()
testLogging { events("passed", "failed", "skipped"); showStandardStreams = true }
}
tasks.withType<Test>().configureEach { finalizedBy(tasks.named("jacocoTestReport")) }
tasks.named<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.named("test"))
reports { xml.required.set(true); html.required.set(true) }
}
@@ -0,0 +1,297 @@
package io.flamenet.obfs4
/**
* The packet layer, which sits inside frames.
*
* uint8 type 0 = payload, 1 = PRNG seed
* uint16 length payload length, big endian
* uint8[] payload
* uint8[] padding zeros, ignored
*
* ⚠️ **Padding lives here, not in the frame.** A frame's length is whatever it
* is; the packet declares how much of it is real. That is what lets obfs4 make
* every write an arbitrary size on the wire without the peer having to care.
*/
object Obfs4Packet {
const val OVERHEAD = 3
const val MAX_PAYLOAD_LENGTH = FrameEncoder.MAX_PAYLOAD_LENGTH - OVERHEAD
const val TYPE_PAYLOAD = 0
const val TYPE_PRNG_SEED = 1
class TooShort(val size: Int) : Exception("packet too short: $size")
/**
* 🔴 A declared length longer than the packet. **Verified in the Swift port:
* deleting this check turns one malformed packet into an out-of-bounds
* crash** — a remote denial of service costing an attacker three bytes.
*/
class DeclaredLengthExceedsPacket(val declared: Int, val available: Int) :
Exception("declared $declared but only $available available")
class PayloadTooLong(val size: Int) : Exception("payload too long: $size")
fun encode(type: Int, payload: ByteArray, padding: Int): ByteArray {
if (payload.size + padding > MAX_PAYLOAD_LENGTH) {
throw PayloadTooLong(payload.size + padding)
}
return byteArrayOf(type.toByte(), (payload.size shr 8).toByte(), payload.size.toByte()) +
payload + ByteArray(padding)
}
/**
* ⚠️ An unknown `type` is **ignored**, not rejected — the reference does this
* so the protocol can gain packet types without breaking older peers. A
* parser that errored here would fail against a future bridge.
*
* Returns `null` as the type when it is unknown.
*/
fun decode(packet: ByteArray): Pair<Int?, ByteArray> {
if (packet.size < OVERHEAD) throw TooShort(packet.size)
val declared = ((packet[1].toInt() and 0xff) shl 8) or (packet[2].toInt() and 0xff)
val available = packet.size - OVERHEAD
if (declared > available) throw DeclaredLengthExceedsPacket(declared, available)
val type = when (packet[0].toInt() and 0xff) {
TYPE_PAYLOAD -> TYPE_PAYLOAD
TYPE_PRNG_SEED -> TYPE_PRNG_SEED
else -> null
}
return type to packet.copyOfRange(OVERHEAD, OVERHEAD + declared)
}
}
/**
* Rejects handshakes that have been seen before.
*
* 🔴 **Anti-replay is probe resistance, not just an integrity check.** Without
* it a scanner can record one real client's handshake and replay it to confirm
* a bridge is there — the server would answer, which is the one thing it must
* never do for anyone who cannot construct a fresh valid handshake.
*
* 🔴 **Share ONE of these across all connections.** A replay is by definition a
* handshake seen on a *different* connection; a per-connection filter catches
* nothing and leaves the probe it exists to stop wide open.
*/
class ReplayFilter(private val lifetimeMillis: Long = 3_600_000) {
private val seen = HashMap<String, Long>()
/** `true` means this MAC has been seen — reject. */
@Synchronized
fun testAndSet(mac: ByteArray, nowMillis: Long = System.currentTimeMillis()): Boolean {
if (seen.size > 1000) seen.entries.removeIf { nowMillis - it.value >= lifetimeMillis }
val key = mac.joinToString("") { "%02x".format(it) }
val first = seen[key]
if (first != null && nowMillis - first < lifetimeMillis) return true
seen[key] = nowMillis
return false
}
}
/** Everything both ends compute the same way. */
object Obfs4Session {
/**
* `Kdf(KEY_SEED, 144)` — a framing key for each direction.
*
* ⚠️ **The halves swap by role.** The client encodes with the first and
* decodes with the second; the server does the reverse. Getting this
* backwards produces two ends that each work perfectly alone and cannot hear
* each other — and only a client-to-server test catches it, never a
* client-only one.
*/
fun directionKeys(keySeed: ByteArray, isClient: Boolean): Pair<ByteArray, ByteArray> {
val okm = Ntor.kdf(keySeed, FrameEncoder.KEY_LENGTH * 2)
val first = okm.copyOfRange(0, FrameEncoder.KEY_LENGTH)
val second = okm.copyOfRange(FrameEncoder.KEY_LENGTH, okm.size)
return if (isClient) first to second else second to first
}
}
/** The data plane: packets in frames, both directions. */
class Obfs4DataPlane(encodeKey: ByteArray, decodeKey: ByteArray) {
private val encoder = FrameEncoder(encodeKey)
private val decoder = FrameDecoder(decodeKey)
private val inbound = mutableListOf<Byte>()
/** The length distribution, rebuilt when a peer sends its seed. */
var lengthDist: WeightedDist? = null
private set
/**
* ⚠️ **Payloads are split, not truncated.** A write larger than one packet
* spans several; a caller that silently dropped the remainder would corrupt
* the stream in a way only the far end notices.
*/
fun send(payload: ByteArray, padding: Int = 0): ByteArray {
var out = ByteArray(0)
var offset = 0
do {
val room = Obfs4Packet.MAX_PAYLOAD_LENGTH - padding
val n = minOf(room, payload.size - offset)
out += encoder.encode(
Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD,
payload.copyOfRange(offset, offset + n), padding))
offset += n
} while (offset < payload.size)
return out
}
/** Padding-only traffic, for cover when there is nothing to say. */
fun sendPadding(length: Int): ByteArray =
encoder.encode(Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, ByteArray(0), length))
/** The server's opening move: hand the client a seed so both pad alike. */
fun sendPrngSeed(seed: ByteArray, padding: Int = 0): ByteArray {
require(seed.size == 24) { "a DRBG seed is 24 bytes" }
return encoder.encode(Obfs4Packet.encode(Obfs4Packet.TYPE_PRNG_SEED, seed, padding))
}
/**
* Feed everything the socket gave you; get back the application data in it.
*
* ⚠️ Frames arrive split across reads and several to a read, so the buffer
* is retained between calls and "need more data" is a normal outcome.
*/
fun receive(bytes: ByteArray, isClient: Boolean = true): ByteArray {
inbound.addAll(bytes.toList())
var application = ByteArray(0)
while (true) {
val packet = try { decoder.decode(inbound) }
catch (_: FrameDecoder.NeedMoreData) { break }
val (type, payload) = Obfs4Packet.decode(packet)
when (type) {
Obfs4Packet.TYPE_PAYLOAD -> application += payload
// 🔴 Only a client adopts a seed. A server that honoured one
// would let any peer dictate its padding, which is a channel a
// censor could use to make traffic recognisable.
Obfs4Packet.TYPE_PRNG_SEED ->
if (isClient && payload.size == 24) {
lengthDist = WeightedDist(payload, 0, FrameEncoder.MAX_SEGMENT_LENGTH, true)
}
else -> Unit // unknown type: ignored by design
}
}
return application
}
}
/** Drives a client from "nothing sent" to "keys established". */
class Obfs4ClientHandshake(
private val nodeId: ByteArray,
private val serverIdentityPublic: ByteArray,
private val sessionPrivate: ByteArray,
private val sessionPublic: ByteArray,
private val sessionRepresentative: ByteArray,
private val epochHour: Long,
) {
private val macKey = Obfs4Handshake.macKey(serverIdentityPublic, nodeId)
private var buffer = ByteArray(0)
class InvalidHandshake : Exception("the bridge's handshake did not verify")
class AuthMismatch : Exception("ntor AUTH mismatch — this is not the bridge we expect")
class NtorFailed : Exception("ntor handshake failure")
/**
* ⚠️ Padding length is the caller's to choose, from
* `CLIENT_MIN_PAD_LENGTH..CLIENT_MAX_PAD_LENGTH`, and must come from a
* CSPRNG: a predictable handshake length is a fingerprint on its own.
*/
fun opening(padding: ByteArray): ByteArray {
require(padding.size in Obfs4Handshake.CLIENT_MIN_PAD_LENGTH..Obfs4Handshake.CLIENT_MAX_PAD_LENGTH) {
"padding outside the permitted range"
}
return Obfs4Handshake.clientMessage(sessionRepresentative, padding, macKey, epochHour)
}
class Established(val keySeed: ByteArray, val leftover: ByteArray)
/**
* `null` means keep reading and stay silent.
*
* 🔴 **Returns the leftover BYTES, not an offset.** An offset would be an
* index into the buffer accumulated across *all* calls, which a caller
* feeding one socket read at a time does not have — the Swift port crashed
* on exactly that the first time a server handshake spanned two reads,
* which is routine over a real network and almost never happens over
* loopback.
*
* ⚠️ Those bytes are already framed traffic, usually the bridge's PRNG seed
* riding in the same segment. Dropping them desynchronises the decoder.
*/
fun receive(bytes: ByteArray): Established? {
buffer += bytes
val parsed = try {
Obfs4Handshake.parseServerMessage(buffer, macKey, epochHour)
} catch (_: Obfs4Handshake.NeedMoreData) {
return null
} catch (e: Exception) {
throw InvalidHandshake()
}
val serverPublic = Elligator2.publicKeyFromRepresentative(parsed.representative)
val result = Ntor.clientHandshake(
sessionPrivate, sessionPublic, serverPublic, serverIdentityPublic, nodeId,
) ?: throw NtorFailed()
// 🔴 Constant-time, and non-negotiable: this is what proves the peer
// holds the bridge's identity key rather than merely having seen a
// handshake.
if (!Ntor.authMatches(result.auth, parsed.auth)) throw AuthMismatch()
return Established(result.keySeed, buffer.copyOfRange(parsed.consumed, buffer.size))
}
}
/**
* Drives a bridge from "a client connected" to "keys established".
*
* 🔴 **Every failure here must be silent to the peer.** obfs4's probe
* resistance is that a bridge which cannot verify a handshake behaves exactly
* like a service that is not there. Errors go to the caller; the caller's only
* correct response is to keep reading nothing and eventually drop the
* connection.
*/
class Obfs4ServerHandshake(
private val nodeId: ByteArray,
private val identityPrivate: ByteArray,
private val identityPublic: ByteArray,
private val sessionPrivate: ByteArray,
private val sessionPublic: ByteArray,
private val sessionRepresentative: ByteArray,
private val epochHour: Long,
) {
private val macKey = Obfs4Handshake.macKey(identityPublic, nodeId)
private var buffer = ByteArray(0)
class InvalidHandshake : Exception("the client's handshake did not verify")
class Replayed : Exception("this handshake has been seen before")
class NtorFailed : Exception("ntor handshake failure")
class Accepted(val keySeed: ByteArray, val reply: ByteArray, val consumed: Int)
/**
* ⚠️ **The reply must be MAC'd under the hour the CLIENT used, not ours.** A
* client whose clock is an hour off is accepted — that is what the ±1 window
* is for — but it will only accept a reply computed under *its* hour. Using
* the server's own works in a test where both share a clock, and fails for
* every real client with a slow one.
*/
fun receive(bytes: ByteArray, padding: ByteArray, replayFilter: ReplayFilter,
nowMillis: Long = System.currentTimeMillis()): Accepted? {
buffer += bytes
val parsed = try {
Obfs4Handshake.parseClientMessage(buffer, macKey, epochHour)
} catch (_: Obfs4Handshake.NeedMoreData) {
return null
} catch (e: Exception) {
throw InvalidHandshake()
}
if (replayFilter.testAndSet(parsed.mac, nowMillis)) throw Replayed()
val clientPublic = Elligator2.publicKeyFromRepresentative(parsed.representative)
val result = Ntor.serverHandshake(
clientPublic, sessionPrivate, sessionPublic, identityPrivate, identityPublic, nodeId,
) ?: throw NtorFailed()
val reply = Obfs4Handshake.serverMessage(
sessionRepresentative, result.auth, padding, macKey, parsed.epochHour)
return Accepted(result.keySeed, reply, parsed.consumed)
}
}
@@ -0,0 +1,296 @@
package io.flamenet.obfs4
import org.bouncycastle.math.ec.rfc7748.X25519Field
/**
* Elligator2 for Curve25519, in the flavour obfs4 uses.
*
* 🔴 **This is why an obfs4 handshake looks like random bytes.** A Curve25519
* public key on the wire is recognisable; its *representative* is not. Get this
* subtly wrong and nothing fails — handshakes still complete, keys still agree —
* the representatives are merely not quite uniform, which is exactly what an
* entropy test looks for. There is no runtime symptom, so the only defence is
* agreeing with the reference byte for byte on inputs neither side chose. See
* `Elligator2Test`, which runs the same vectors the Swift port does.
*
* ⚠️ **Built on BouncyCastle's `X25519Field`, not on hand-written arithmetic.**
* The Swift port had to write its own field because CryptoKit exposes nothing
* below X25519; the JVM does not have that hole, and taking a vetted
* constant-time implementation over rolling one is the same call `:engine`
* already made for Ed25519.
*
* ⚠️ The two ports therefore share no code — only vectors. That is the point:
* two independent implementations agreeing with the same reference is a
* stronger statement than one implementation agreeing with itself twice.
*/
object Elligator2 {
/** The Montgomery curve constant, A = 486662. */
private val A: IntArray = X25519Field.create().also {
it[0] = 486662
}
private val negA: IntArray = X25519Field.create().also { X25519Field.negate(A, it) }
/** −2, the "non-square" the inverse map is built around. */
private val negTwo: IntArray = X25519Field.create().also {
val two = X25519Field.create(); two[0] = 2
X25519Field.negate(two, it)
}
private fun fe(bytes: ByteArray): IntArray {
require(bytes.size == 32) { "a field element is 32 bytes" }
val z = X25519Field.create()
X25519Field.decode(bytes, 0, z)
return z
}
private fun encode(z: IntArray): ByteArray {
val out = ByteArray(32)
val copy = z.copyOf()
X25519Field.normalize(copy)
X25519Field.encode(copy, out, 0)
return out
}
/** The low bit of the canonical representative — Elligator's sign. */
private fun isNegative(z: IntArray): Boolean = (encode(z)[0].toInt() and 1) == 1
/** √−1 mod p. */
private val SQRT_M1: IntArray = fe(
byteArrayOf(
0xb0.toByte(), 0xa0.toByte(), 0x0e, 0x4a, 0x27, 0x1b, 0xee.toByte(), 0xc4.toByte(),
0x78, 0xe4.toByte(), 0x2f, 0xad.toByte(), 0x06, 0x18, 0x43, 0x2f,
0xa7.toByte(), 0xd7.toByte(), 0xfb.toByte(), 0x3d, 0x99.toByte(), 0x00, 0x4d, 0x2b,
0x0b, 0xdf.toByte(), 0xc1.toByte(), 0x4f, 0x80.toByte(), 0x24, 0x83.toByte(), 0x2b,
)
)
/** x^((p−5)/8), the ladder every square root here is built on. */
private fun powP58(x: IntArray): IntArray {
fun sqrN(a: IntArray, n: Int): IntArray =
X25519Field.create().also { X25519Field.sqr(a, n, it) }
fun mul(a: IntArray, b: IntArray): IntArray =
X25519Field.create().also { X25519Field.mul(a, b, it) }
val z2 = sqrN(x, 1)
val z8 = sqrN(z2, 2)
val z9 = mul(x, z8)
val z11 = mul(z2, z9)
val z22 = sqrN(z11, 1)
val z5 = mul(z9, z22)
val z10 = mul(sqrN(z5, 5), z5)
val z20 = mul(sqrN(z10, 10), z10)
val z40 = mul(sqrN(z20, 20), z20)
val z50 = mul(sqrN(z40, 10), z10)
val z100 = mul(sqrN(z50, 50), z50)
val z200 = mul(sqrN(z100, 100), z100)
val z250 = mul(sqrN(z200, 50), z50)
return mul(sqrN(z250, 2), x)
}
/** `bit == 1 ? a : b`, with no branch and no data-dependent access. */
private fun cselect(a: IntArray, b: IntArray, bit: Int): IntArray {
val mask = -(bit and 1) // 0 or -1, i.e. all bits
return IntArray(a.size) { (a[it] and mask) or (b[it] and mask.inv()) }
}
private fun equal(a: IntArray, b: IntArray): Boolean {
val x = a.copyOf(); val y = b.copyOf()
X25519Field.normalize(x); X25519Field.normalize(y)
return X25519Field.areEqualVar(x, y)
}
/**
* √(u/v), and whether the ratio was actually square.
*
* 🔴 **Written out rather than delegated to `X25519Field.sqrtRatioVar`,
* which is not a drop-in.** Elligator needs the root *even when the ratio
* is not square* — it is √(i·u/v) then, and the algorithm uses it to pick a
* branch rather than to detect an error. BouncyCastle only promises a
* usable root in the square case. Handing its output to the map produced
* zeros for 120 of 256 vectors: half-right, entirely useless, and exactly
* the kind of thing that looks like a working implementation.
*
* ⚠️ The arithmetic underneath is still BouncyCastle's. What is written
* here is the exponent chain and the branch selection, not field ops.
*
* ⚠️ Canonicalised to the non-negative root: ±r map to the same point, so
* no round-trip test would catch the sign being left to chance.
*/
private fun sqrtRatio(u: IntArray, v: IntArray): Pair<IntArray, Boolean> {
fun mul(a: IntArray, b: IntArray): IntArray =
X25519Field.create().also { X25519Field.mul(a, b, it) }
val v3 = mul(X25519Field.create().also { X25519Field.sqr(v, it) }, v)
val v7 = mul(X25519Field.create().also { X25519Field.sqr(v3, it) }, v)
var r = mul(mul(u, v3), powP58(mul(u, v7)))
val check = mul(v, X25519Field.create().also { X25519Field.sqr(r, it) })
val uNeg = X25519Field.create().also { X25519Field.negate(u, it) }
val correct = equal(check, u)
val flipped = equal(check, uNeg)
val flippedI = equal(check, mul(uNeg, SQRT_M1))
if (flipped || flippedI) r = mul(r, SQRT_M1)
if (isNegative(r)) X25519Field.negate(r, r)
return r to (correct || flipped)
}
// MARK: - The direct map
/**
* A representative to the Montgomery `u` — a public key.
*
* ⚠️ **The top two bits of a representative are not data.** obfs4 encodes a
* representative in 254 bits and fills the rest with noise, so they are
* masked off here. Feeding the raw 32 bytes in produces a different point
* and a handshake that fails for no visible reason.
*/
fun publicKeyFromRepresentative(representative: ByteArray): ByteArray {
require(representative.size == 32) { "a representative is 32 bytes" }
val clamped = representative.copyOf()
clamped[31] = (clamped[31].toInt() and 63).toByte()
return encode(montgomeryFlavor(fe(clamped)).first)
}
/** `MontgomeryFlavor`: representative → (u, v). */
private fun montgomeryFlavor(r: IntArray): Pair<IntArray, IntArray> {
val one = X25519Field.create(); X25519Field.one(one)
val t1 = X25519Field.create()
X25519Field.sqr(r, t1)
X25519Field.add(t1, t1, t1) // 2r²
val u = X25519Field.create()
X25519Field.add(t1, one, u) // 2r² + 1
val t2 = X25519Field.create()
X25519Field.sqr(u, t2)
// numerator
val t3 = X25519Field.create()
val aSquared = X25519Field.create()
X25519Field.sqr(A, aSquared)
X25519Field.mul(aSquared, t1, t3)
X25519Field.sub(t3, t2, t3)
X25519Field.mul(t3, A, t3)
// denominator
val denominator = X25519Field.create()
X25519Field.mul(t2, u, denominator)
X25519Field.mul(denominator, t3, denominator)
// 🔴 **The root replaces the denominator from here on.** Both reference
// implementations write the result back over the argument
// (`invsqrt(t3, t3)`, `t1.SqrtRatio(ONE, t1)`), so every later line
// means √(1/denominator), not the denominator. Reading it the other way
// gave the Swift port 256 wrong answers out of 256, each a
// plausible-looking 32 bytes.
val (root, isSquare) = sqrtRatio(one, denominator)
val uOut = X25519Field.create()
val vOut = X25519Field.create()
if (isSquare) {
X25519Field.one(uOut)
X25519Field.one(vOut)
} else {
X25519Field.sqr(r, uOut)
X25519Field.mul(uOut, U_FACTOR, uOut)
X25519Field.mul(r, V_FACTOR, vOut)
}
X25519Field.mul(vOut, t3, vOut)
X25519Field.mul(vOut, root, vOut)
val rootSquared = X25519Field.create()
X25519Field.sqr(root, rootSquared)
X25519Field.mul(uOut, negA, uOut)
X25519Field.mul(uOut, t3, uOut)
X25519Field.mul(uOut, t2, uOut)
X25519Field.mul(uOut, rootSquared, uOut)
// ⚠️ The sign is chosen AGAINST `isSquare`: negate exactly when the two
// disagree. Reading this as "negate when not square" is the easy
// misreading and gives the wrong point half the time.
if (isSquare != isNegative(vOut)) X25519Field.negate(vOut, vOut)
return uOut to vOut
}
// MARK: - The inverse map
/**
* A public key and a tweak byte to a uniform representative, or `null`.
*
* 🔴 **`null` is the normal case, not an error.** Only about half of all
* Curve25519 points have a representative; obfs4 discards the keypair and
* generates another. An implementation that always succeeds is as broken as
* one that computes the wrong bytes, and rather harder to notice.
*/
fun representativeForPublicKey(publicKey: ByteArray, tweak: Int): ByteArray? {
require(publicKey.size == 32) { "a public key is 32 bytes" }
val u = fe(publicKey)
val one = X25519Field.create(); X25519Field.one(one)
val uPlusA = X25519Field.create()
X25519Field.add(u, A, uPlusA)
val t3 = X25519Field.create()
X25519Field.mul(u, uPlusA, t3)
X25519Field.mul(t3, negTwo, t3) // −2·u·(u+A)
val (isr, isSquare) = sqrtRatio(one, t3)
// ⚠️ The one variable-time branch, and it is deliberate: Monocypher
// makes the same call and explains why. It reveals only how many tries
// it took to find a representable key, which is safe as long as keys
// are tried at random.
if (!isSquare) return null
// Multiply by u if v is positive, by u+A otherwise. Constant-time,
// because the tweak comes off the same keystream as the private key.
//
// 🔴 **Not `X25519Field.cmov`, which silently did nothing here.**
// Passing `1` as its condition left the destination untouched, so 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. It took a diagnostic printing both candidates to see
// it; the failing assertion said only "wrong bytes". Building the mask
// here makes the convention explicit instead of assumed.
val chosen = cselect(uPlusA, u, tweak and 1)
val result = X25519Field.create()
X25519Field.mul(chosen, isr, result)
// ⚠️ Canonical sign: negate so that 2·result is even, pinning it to the
// lower half of the field. That is also what keeps a representative
// inside 254 bits, so the direct map's mask does not destroy it.
val doubled = X25519Field.create()
X25519Field.add(result, result, doubled)
X25519Field.cnegate(if (isNegative(doubled)) 1 else 0, result)
val out = encode(result)
// Two bits of noise, so a representative fills 256 bits and not 254.
out[31] = (out[31].toInt() or (tweak and 0xc0)).toByte()
return out
}
private val U_FACTOR: IntArray = fe(
byteArrayOf(
0x8d.toByte(), 0xbe.toByte(), 0xe2.toByte(), 0x6b, 0xb1.toByte(), 0xc9.toByte(), 0x23, 0x76,
0x0e, 0x37, 0xa0.toByte(), 0xa5.toByte(), 0xf2.toByte(), 0xcf.toByte(), 0x79, 0xa1.toByte(),
0xb1.toByte(), 0x50, 0x08, 0x84.toByte(), 0xcd.toByte(), 0xfe.toByte(), 0x65, 0xa9.toByte(),
0xe9.toByte(), 0x41, 0x7c, 0x60, 0xff.toByte(), 0xb6.toByte(), 0xf9.toByte(), 0x28,
)
)
private val V_FACTOR: IntArray = fe(
byteArrayOf(
0x3e, 0x5f, 0xf1.toByte(), 0xb5.toByte(), 0xd8.toByte(), 0xe4.toByte(), 0x11, 0x3b,
0x87.toByte(), 0x1b, 0xd0.toByte(), 0x52, 0xf9.toByte(), 0xe7.toByte(), 0xbc.toByte(), 0xd0.toByte(),
0x58, 0x28, 0x04, 0xc2.toByte(), 0x66, 0xff.toByte(), 0xb2.toByte(), 0xd4.toByte(),
0xf4.toByte(), 0x20, 0x3e, 0xb0.toByte(), 0x7f, 0xdb.toByte(), 0x7c, 0x54,
)
)
}
@@ -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,171 @@
package io.flamenet.obfs4
import java.security.SecureRandom
/**
* Go's `math/rand` semantics, driven by obfs4's SipHash DRBG.
*
* 🔴 **This exists for indistinguishability, not interoperability.** Padding is
* padding — a peer reads the declared payload length and ignores the rest, so
* *any* distribution talks to a real bridge. But obfs4's purpose is to look like
* other obfs4 traffic, and packet length is among the first things a censor
* would model. A port that pads differently works perfectly and is
* fingerprintable, which is the more dangerous kind of wrong.
*
* ⚠️ **These are Go's algorithms, quirks included.** `float64` divides an
* `int63` by 2^63 and resamples on the one-in-2^53 chance it rounds to 1.0 —
* Go's own comment says a cleaner form exists and was rejected to preserve the
* value stream. `perm` swaps `m[0]` with itself on its first iteration for the
* same reason. Both quirks consume randomness, so "improving" either
* desynchronises every draw that follows.
*/
class GoRandom(seed: ByteArray) {
private val drbg = LengthDrbg(seed)
/** The DRBG block, big-endian, with the sign bit cleared. */
fun int63(): Long {
val block = drbg.nextBlock()
var v = 0L
for (b in block) v = (v shl 8) or (b.toLong() and 0xff)
return v and Long.MAX_VALUE
}
private fun int31(): Int = (int63() ushr 32).toInt()
/**
* ⚠️ Rejection sampling, exactly as Go does it. A simple `% n` would be
* biased *and* would consume a different number of draws.
*/
private fun int31n(n: Int): Int {
require(n > 0)
if (n and (n - 1) == 0) return int31() and (n - 1) // power of two
val max = ((1L shl 31) - 1 - (1L shl 31) % n).toInt()
var v = int31()
while (v > max) v = int31()
return v % n
}
fun intn(n: Int): Int {
require(n > 0) { "Intn requires a positive bound" }
return int31n(n)
}
fun float64(): Double {
while (true) {
val f = int63().toDouble() / 9.223372036854776E18 // 2^63
if (f != 1.0) return f
}
}
/**
* ⚠️ Starts at 0, so the first iteration swaps `m[0]` with itself. Useless
* work that consumes a draw — and removing it changes every later value.
*/
fun perm(n: Int): IntArray {
val m = IntArray(n)
for (i in 0 until n) {
val j = intn(i + 1)
m[i] = m[j]
m[j] = i
}
return m
}
}
/**
* obfs4's packet-length distribution: a weighted sample over a subset of
* lengths, derived from a seed.
*
* ⚠️ **The server sends its seed to the client inside the connection**, so both
* ends pad from the same distribution. That is why the derivation must match
* lyrebird's exactly even though sampling need not.
*
* 🔴 **Sampling draws from the system CSPRNG, not the seed.** The reference is
* explicit about this, and it matters: a seeded sampler would make one
* connection's padding predictable from another's.
*/
class WeightedDist(seed: ByteArray, val minValue: Int, val maxValue: Int, val biased: Boolean) {
var values: IntArray = IntArray(0); private set
var weights: DoubleArray = DoubleArray(0); private set
private var prob: DoubleArray = DoubleArray(0)
private var alias: IntArray = IntArray(0)
private val random = SecureRandom()
init {
require(maxValue > minValue) { "an empty distribution has nothing to sample" }
reset(seed)
}
fun reset(seed: ByteArray) {
val rng = GoRandom(seed)
genValues(rng)
if (biased) genBiasedWeights(rng) else genUniformWeights(rng)
genTables()
}
private fun genValues(rng: GoRandom) {
var nValues = (maxValue + 1) - minValue
val permuted = rng.perm(nValues)
// ⚠️ The clamp happens AFTER the full permutation is drawn. Permuting
// only the clamped count consumes far less randomness and every later
// draw would differ.
nValues = maxOf(nValues, MIN_VALUES)
nValues = minOf(nValues, MAX_VALUES)
nValues = rng.intn(nValues) + 1
values = permuted.copyOfRange(0, nValues)
}
private fun genBiasedWeights(rng: GoRandom) {
weights = DoubleArray(values.size)
var culmProb = 0.0
for (i in values.indices) {
val p = (1.0 - culmProb) * rng.float64()
weights[i] = p
culmProb += p
}
}
private fun genUniformWeights(rng: GoRandom) {
weights = DoubleArray(values.size) { rng.float64() }
}
/**
* Vose's alias method, in the reference's exact form — including that
* leftovers in `large` are assigned before leftovers in `small`.
*/
private fun genTables() {
val n = weights.size
val sum = weights.sum()
val aliasTable = IntArray(n)
val probTable = DoubleArray(n)
val scaled = DoubleArray(n) { weights[it] * n / sum }
val small = ArrayDeque<Int>()
val large = ArrayDeque<Int>()
for (i in 0 until n) if (scaled[i] < 1.0) small.addLast(i) else large.addLast(i)
while (small.isNotEmpty() && large.isNotEmpty()) {
val l = small.removeFirst()
val g = large.removeFirst()
probTable[l] = scaled[l]
aliasTable[l] = g
scaled[g] = (scaled[g] + scaled[l]) - 1.0
if (scaled[g] < 1.0) small.addLast(g) else large.addLast(g)
}
for (g in large) probTable[g] = 1.0
for (l in small) probTable[l] = 1.0
prob = probTable
alias = aliasTable
}
/** 🔴 Draws from the system CSPRNG deliberately — see the class comment. */
fun sample(): Int {
val i = random.nextInt(values.size)
val idx = if (random.nextDouble() <= prob[i]) i else alias[i]
return minValue + values[idx]
}
companion object {
private const val MIN_VALUES = 1
private const val MAX_VALUES = 100
}
}
@@ -0,0 +1,211 @@
package io.flamenet.obfs4
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* The obfs4 handshake messages.
*
* client → server: X ‖ P_C ‖ M_C ‖ MAC(X ‖ P_C ‖ M_C ‖ E)
* server → client: Y ‖ AUTH ‖ P_S ‖ M_S ‖ MAC(Y ‖ AUTH ‖ P_S ‖ M_S ‖ E)
*
* where `X`/`Y` are Elligator2 representatives, `P` is random padding, `M` is a
* 16-byte mark, `E` is the hours since the Unix epoch **as a decimal string**,
* and the MAC key is `serverIdentityPublic ‖ nodeId`.
*
* 🔴 **This layer is what makes obfs4 probe-resistant.** A scanner that connects
* and sends anything cannot produce a valid mark without the bridge's public
* key, so the server never answers — it just waits, exactly like a service that
* is not there. Every rejection path here must stay silent to the *peer*:
* returning an error to the caller is right, writing anything back is not.
*/
object Obfs4Handshake {
const val MARK_LENGTH = 16
const val MAC_LENGTH = 16
const val MAX_HANDSHAKE_LENGTH = 8192
private const val REPRESENTATIVE_LENGTH = 32
private const val AUTH_LENGTH = 32
const val CLIENT_MIN_HANDSHAKE_LENGTH = REPRESENTATIVE_LENGTH + MARK_LENGTH + MAC_LENGTH
const val SERVER_MIN_HANDSHAKE_LENGTH =
REPRESENTATIVE_LENGTH + AUTH_LENGTH + MARK_LENGTH + MAC_LENGTH
/**
* The server's first frame carries a DRBG seed inline, and the client's
* padding floor is sized so a client handshake is never shorter than a
* server one — otherwise the two directions are distinguishable by length
* alone.
*/
const val INLINE_SEED_FRAME_LENGTH = FrameEncoder.OVERHEAD + 3 + 24
const val CLIENT_MIN_PAD_LENGTH =
(SERVER_MIN_HANDSHAKE_LENGTH + INLINE_SEED_FRAME_LENGTH) - CLIENT_MIN_HANDSHAKE_LENGTH
const val CLIENT_MAX_PAD_LENGTH = MAX_HANDSHAKE_LENGTH - CLIENT_MIN_HANDSHAKE_LENGTH
const val SERVER_MIN_PAD_LENGTH = 0
const val SERVER_MAX_PAD_LENGTH =
MAX_HANDSHAKE_LENGTH - (SERVER_MIN_HANDSHAKE_LENGTH + INLINE_SEED_FRAME_LENGTH)
/**
* The MAC key: the server's identity **public** key then the node id. Both
* are public, which is the point — a client holding the bridge line can
* compute marks, and a scanner that does not, cannot.
*/
fun macKey(serverIdentityPublic: ByteArray, nodeId: ByteArray): ByteArray =
serverIdentityPublic + nodeId
private fun hmac128(key: ByteArray, vararg parts: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
for (p in parts) mac.update(p)
return mac.doFinal().copyOfRange(0, 16)
}
fun mark(macKey: ByteArray, representative: ByteArray): ByteArray =
hmac128(macKey, representative)
/**
* ⚠️ The epoch hour enters the MAC as its **decimal string**, not as bytes.
* `496862` is six ASCII characters here; a binary encoding produces a MAC
* that is self-consistent and interoperates with nothing.
*/
private fun epochBytes(hour: Long): ByteArray = hour.toString().toByteArray()
/**
* 🔴 Everything below takes the hour as a **parameter**. A MAC that silently
* depends on the wall clock cannot be tested against a recorded handshake,
* because the recording rots within the hour.
*/
fun currentEpochHour(nowMillis: Long = System.currentTimeMillis()): Long =
nowMillis / 1000 / 3600
fun clientMessage(
representative: ByteArray, padding: ByteArray, macKey: ByteArray, epochHour: Long,
): ByteArray {
val prefix = representative + padding + mark(macKey, representative)
return prefix + hmac128(macKey, prefix, epochBytes(epochHour))
}
fun serverMessage(
representative: ByteArray, auth: ByteArray, padding: ByteArray,
macKey: ByteArray, epochHour: Long,
): ByteArray {
val prefix = representative + auth + padding + mark(macKey, representative)
return prefix + hmac128(macKey, prefix, epochBytes(epochHour))
}
/** ⚠️ **Not a rejection.** The mark may simply not have arrived yet. */
class NeedMoreData : Exception("more handshake data needed")
class InvalidHandshake : Exception("invalid handshake")
class MacMismatch : Exception("handshake MAC mismatch")
class ParsedClient(
val representative: ByteArray,
val padding: ByteArray,
/** Which of hour−1, hour, hour+1 verified. The reply must echo it. */
val epochHour: Long,
val consumed: Int,
/** The received MAC, which the caller must feed to a replay filter. */
val mac: ByteArray,
)
class ParsedServer(
val representative: ByteArray,
val auth: ByteArray,
val padding: ByteArray,
val consumed: Int,
)
/**
* The server's side.
*
* 🔴 **Searches only the tail, deliberately.** The client cannot send
* anything valid after its MAC — it has no server ephemeral key yet — so the
* mark must sit exactly 32 bytes from the end. Trailing bytes make the
* handshake invalid, full stop.
*
* ⚠️ **All three epoch hours are evaluated even after one matches.** The
* reference does this to flatten timing, and so does this: breaking early
* would leak which hour a client's clock is on.
*/
fun parseClientMessage(buffer: ByteArray, macKey: ByteArray, epochHour: Long): ParsedClient {
if (buffer.size < CLIENT_MIN_HANDSHAKE_LENGTH) throw NeedMoreData()
val representative = buffer.copyOfRange(0, REPRESENTATIVE_LENGTH)
val expectedMark = mark(macKey, representative)
val startPos = REPRESENTATIVE_LENGTH + CLIENT_MIN_PAD_LENGTH
val endPos = minOf(buffer.size, MAX_HANDSHAKE_LENGTH)
fun giveUp(): Nothing =
if (buffer.size >= MAX_HANDSHAKE_LENGTH) throw InvalidHandshake() else throw NeedMoreData()
if (endPos - startPos < MARK_LENGTH + MAC_LENGTH) giveUp()
val pos = endPos - (MARK_LENGTH + MAC_LENGTH)
if (!Secretbox.constantTimeEquals(buffer.copyOfRange(pos, pos + MARK_LENGTH), expectedMark)) {
giveUp()
}
val receivedMac = buffer.copyOfRange(pos + MARK_LENGTH, pos + MARK_LENGTH + MAC_LENGTH)
var matched: Long? = null
for (offset in listOf(0L, -1L, 1L)) {
val hour = epochHour + offset
val computed = hmac128(macKey, buffer.copyOfRange(0, pos + MARK_LENGTH), epochBytes(hour))
if (Secretbox.constantTimeEquals(computed, receivedMac)) matched = hour
}
val hour = matched ?: throw MacMismatch()
// ⚠️ Kept to match the reference, though the tail search is what actually
// rejects trailing bytes: appending moves the window and the mark stops
// matching. This can only fire on an oversized buffer.
if (buffer.size != pos + MARK_LENGTH + MAC_LENGTH) throw InvalidHandshake()
return ParsedClient(
representative, buffer.copyOfRange(REPRESENTATIVE_LENGTH, pos),
hour, pos + MARK_LENGTH + MAC_LENGTH, receivedMac,
)
}
/**
* The client's side.
*
* ⚠️ **A substring search, not a tail check.** The server *does* send framed
* traffic immediately after its handshake — its first data frame is usually
* in the same segment — so the mark can be anywhere from
* `SERVER_MIN_HANDSHAKE_LENGTH` onwards. Using the tail trick here, the
* obvious symmetry, fails against every real bridge.
*/
fun parseServerMessage(buffer: ByteArray, macKey: ByteArray, epochHour: Long): ParsedServer {
if (buffer.size < SERVER_MIN_HANDSHAKE_LENGTH) throw NeedMoreData()
val representative = buffer.copyOfRange(0, REPRESENTATIVE_LENGTH)
val auth = buffer.copyOfRange(REPRESENTATIVE_LENGTH, REPRESENTATIVE_LENGTH + AUTH_LENGTH)
val expectedMark = mark(macKey, representative)
val startPos = REPRESENTATIVE_LENGTH + AUTH_LENGTH + SERVER_MIN_PAD_LENGTH
val endPos = minOf(buffer.size, MAX_HANDSHAKE_LENGTH)
if (endPos - startPos < MARK_LENGTH + MAC_LENGTH) throw NeedMoreData()
val pos = indexOf(expectedMark, buffer, startPos, endPos)
if (pos < 0 || pos + MARK_LENGTH + MAC_LENGTH > endPos) {
if (buffer.size >= MAX_HANDSHAKE_LENGTH) throw InvalidHandshake() else throw NeedMoreData()
}
val receivedMac = buffer.copyOfRange(pos + MARK_LENGTH, pos + MARK_LENGTH + MAC_LENGTH)
val computed = hmac128(macKey, buffer.copyOfRange(0, pos + MARK_LENGTH), epochBytes(epochHour))
if (!Secretbox.constantTimeEquals(computed, receivedMac)) throw MacMismatch()
return ParsedServer(
representative, auth, buffer.copyOfRange(startPos, pos),
pos + MARK_LENGTH + MAC_LENGTH,
)
}
private fun indexOf(needle: ByteArray, haystack: ByteArray, from: Int, to: Int): Int {
if (from < 0 || to > haystack.size || to - from < needle.size) return -1
var i = from
outer@ while (i <= to - needle.size) {
for (j in needle.indices) if (haystack[i + j] != needle[j]) { i++; continue@outer }
return i
}
return -1
}
}
@@ -0,0 +1,190 @@
package io.flamenet.obfs4
import org.bouncycastle.math.ec.rfc7748.X25519Field
/**
* X25519 scalar multiplication, including the **cofactor-dirty** variant obfs4
* uses for key generation.
*
* 🔴 **The dirty variant is why obfs4 public keys differ from every normal
* X25519 implementation's.** A conventional implementation clamps the scalar,
* clearing the cofactor and confining the result to the prime-order subgroup —
* and a point known to be in that subgroup is *distinguishable*, which undoes
* the whole point of Elligator2. obfs4 keeps the cofactor component so the
* public key ranges over the full curve.
*
* ⚠️ **BouncyCastle's `X25519.scalarMult` cannot be used for it.** That clamps
* internally and runs 255 bits; the dirty variant needs the *combined* scalar
* fed to a **256-bit** ladder with no re-clamping. The field arithmetic here is
* still BouncyCastle's — only the ladder and the scalar handling are written
* out.
*
* Follows Monocypher's `crypto_x25519_dirty_small`, which decouples the
* cofactor from the main factor and multiplies a single combined scalar by a
* base point of order 8·L. lyrebird instead uses the Edwards route; the two
* agree, and the vectors check that rather than taking it on faith.
*/
object Ladder {
/**
* A base point of order 8·L, so multiplying by it leaves the cofactor
* intact.
*
* ⚠️ Its low-order component is not arbitrary — it was chosen so this route
* lands on the same public key as the Edwards route obfs4 actually uses.
* Any other order-8 point gives a self-consistent implementation that no
* longer interoperates.
*/
private val DIRTY_BASE_POINT = byteArrayOf(
0xd8.toByte(), 0x86.toByte(), 0x1a, 0xa2.toByte(), 0x78, 0x7a, 0xd9.toByte(), 0x26,
0x8b.toByte(), 0x74, 0x74, 0xb6.toByte(), 0x82.toByte(), 0xe3.toByte(), 0xbe.toByte(), 0xc3.toByte(),
0xce.toByte(), 0x36, 0x9a.toByte(), 0x1e, 0x5e, 0x31, 0x47, 0xa2.toByte(),
0x6d, 0x37, 0x7c, 0xfd.toByte(), 0x20, 0xb5.toByte(), 0xdf.toByte(), 0x75,
)
private val BASE_POINT_9 = ByteArray(32).also { it[0] = 9 }
/** L, the order of the prime-order subgroup, as eight 32-bit words. */
private val GROUP_ORDER = longArrayOf(
0x5cf5d3edL, 0x5812631aL, 0xa2f79cd6L, 0x14def9deL,
0x00000000L, 0x00000000L, 0x00000000L, 0x10000000L,
)
/** The standard X25519 clamp. */
fun trimScalar(s: ByteArray): ByteArray = s.copyOf().also {
it[0] = (it[0].toInt() and 248).toByte()
it[31] = (it[31].toInt() and 127).toByte()
it[31] = (it[31].toInt() or 64).toByte()
}
/**
* Add `(x and 7)·L` to a 256-bit little-endian scalar.
*
* 🔴 **This is the step that puts the cofactor back.** Trimming cleared the
* three low bits, but those bits still carry a main-factor component that
* has to be removed for X25519 compatibility; adding `lsb·L` (mod 8·L)
* recombines the two into one scalar. Drop it and you get an ordinary clean
* public key — which works, agrees on shared secrets, and is exactly the
* distinguishable thing obfs4 avoids.
*/
fun addGroupOrderTimes(x: Int, s: ByteArray): ByteArray {
val out = s.copyOf()
val mod8 = (x and 7).toLong()
var carry = 0L
for (i in 0 until 8) {
var word = 0L
for (j in 3 downTo 0) word = (word shl 8) or (out[4 * i + j].toLong() and 0xff)
carry += word + GROUP_ORDER[i] * mod8
for (j in 0 until 4) out[4 * i + j] = ((carry ushr (8 * j)) and 0xff).toByte()
carry = carry ushr 32
}
return out
}
/** `bit == 1` swaps `a` and `b`, without branching on the bit. */
private fun cswap(bit: Int, a: IntArray, b: IntArray) {
// ⚠️ An explicit mask, deliberately. `X25519Field.cmov` was found to do
// nothing at all when handed a plain `1`, so nothing in this file
// assumes BouncyCastle's conditional helpers take a boolean-shaped
// condition. See Elligator2.cselect.
val mask = -(bit and 1)
for (i in a.indices) {
val t = mask and (a[i] xor b[i])
a[i] = a[i] xor t
b[i] = b[i] xor t
}
}
/**
* The Montgomery ladder in projective coordinates, ref10's, via Monocypher.
*
* ⚠️ **`bits` is 256 for the dirty variant, not the usual 255.** The
* combined scalar can exceed 2^255 once `lsb·L` has been added, so a ladder
* that stops at bit 254 silently drops the top bit and returns a wrong
* point for a fraction of keys — a bug any small sample would miss.
*/
fun ladder(scalar: ByteArray, base: ByteArray, bits: Int): ByteArray {
val x1 = X25519Field.create().also { X25519Field.decode(base, 0, it) }
val x2 = X25519Field.create().also { X25519Field.one(it) }
val z2 = X25519Field.create()
val x3 = x1.copyOf()
val z3 = X25519Field.create().also { X25519Field.one(it) }
val t0 = X25519Field.create()
val t1 = X25519Field.create()
val t2 = X25519Field.create()
var swap = 0
for (pos in bits - 1 downTo 0) {
val bit = (scalar[pos shr 3].toInt() shr (pos and 7)) and 1
swap = swap xor bit
cswap(swap, x2, x3)
cswap(swap, z2, z3)
swap = bit // the xor trick: one swap deferred
X25519Field.sub(x3, z3, t0)
X25519Field.sub(x2, z2, t1)
X25519Field.add(x2, z2, x2)
X25519Field.add(x3, z3, z2)
X25519Field.mul(t0, x2, z3)
X25519Field.mul(z2, t1, z2)
X25519Field.sqr(t1, t0)
X25519Field.sqr(x2, t1)
X25519Field.add(z3, z2, x3)
X25519Field.sub(z3, z2, z2)
X25519Field.mul(t1, t0, x2)
X25519Field.sub(t1, t0, t1)
X25519Field.sqr(z2, z2)
X25519Field.mul(t1, 121666, z3)
X25519Field.sqr(x3, x3)
X25519Field.add(t0, z3, t2)
X25519Field.mul(x1, z2, z3)
X25519Field.mul(t1, t2, z2)
}
// ⚠️ Compensates for the xor trick. Omitting it gives a result that is
// right for exactly the scalars whose bottom bit happens to agree.
cswap(swap, x2, x3)
cswap(swap, z2, z3)
val inv = X25519Field.create().also { X25519Field.inv(z2, it) }
val out = X25519Field.create().also { X25519Field.mul(x2, inv, it) }
X25519Field.normalize(out)
return ByteArray(32).also { X25519Field.encode(out, it, 0) }
}
/**
* A private key to its **cofactor-dirty** X25519 public key.
*
* The private key must be the full 32 bytes of entropy. Pre-clamping it
* yields non-uniformly distributed representatives, which is the whole
* failure this module exists to avoid.
*/
fun dirtyPublicKey(privateKey: ByteArray): ByteArray {
require(privateKey.size == 32) { "a private key is 32 bytes" }
val combined = addGroupOrderTimes(privateKey[0].toInt(), trimScalar(privateKey))
return ladder(combined, DIRTY_BASE_POINT, 256)
}
/**
* The ordinary, cofactor-cleared public key.
*
* ⚠️ **A bridge's long-term identity key is CLEAN.** Only the ephemeral
* session keys keep their cofactor, because only those travel as
* representatives. (A dirty identity key does in fact still complete a
* handshake — X25519 clamps, so the order-8 component cancels on both
* sides — but every other implementation and every bridge-line tool expects
* the clean one.)
*/
fun publicKey(privateKey: ByteArray): ByteArray =
ladder(trimScalar(privateKey), BASE_POINT_9, 255)
/**
* Ordinary X25519.
*
* 🔴 The result must be checked for all-zeros by the caller: a peer that
* sends a low-order point drives every shared secret to zero, which a
* handshake has to reject rather than key from.
*/
fun sharedSecret(privateKey: ByteArray, peerPublicKey: ByteArray): ByteArray =
ladder(trimScalar(privateKey), peerPublicKey, 255)
}
@@ -0,0 +1,124 @@
package io.flamenet.obfs4
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.params.HKDFParameters
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* obfs4's ntor handshake.
*
* 🔴 **The reference's own comment contradicts the reference's own code, and
* the code is the protocol.** lyrebird's `ntorCommon` documents its input
* ordering as `ID | B | Y | X | PROTOID`, then builds
* `B | B | X | Y | PROTOID | ID` — with `B` appearing **twice**, because the
* buffer is constructed with it and then written to again. Port the comment and
* you get a handshake that computes cleanly, yields a respectable-looking
* 32-byte KEY_SEED, and cannot talk to a single real bridge.
*
* ⚠️ This is not Tor's ntor. It is obfs4's variant, and the difference is
* exactly that quirk.
*/
object Ntor {
const val NODE_ID_LENGTH = 20
const val KEY_SEED_LENGTH = 32
const val AUTH_LENGTH = 32
private val PROTO_ID = "ntor-curve25519-sha256-1".toByteArray()
private val T_MAC = PROTO_ID + ":mac".toByteArray()
private val T_KEY = PROTO_ID + ":key_extract".toByteArray()
private val T_VERIFY = PROTO_ID + ":key_verify".toByteArray()
private val M_EXPAND = PROTO_ID + ":key_expand".toByteArray()
data class Result(val keySeed: ByteArray, val auth: ByteArray) {
override fun equals(other: Any?): Boolean =
other is Result && keySeed.contentEquals(other.keySeed) && auth.contentEquals(other.auth)
override fun hashCode(): Int = keySeed.contentHashCode() * 31 + auth.contentHashCode()
}
/**
* The client half. `null` means the handshake must be abandoned.
*
* The caller must **also** compare `auth` against the server's, in constant
* time, with [authMatches]. A non-null result proves only that this side
* computed something.
*/
fun clientHandshake(
clientPrivate: ByteArray, clientPublic: ByteArray,
serverPublic: ByteArray, idPublic: ByteArray, nodeId: ByteArray,
): Result? {
// EXP(Y,x) | EXP(B,x)
val exp1 = Ladder.sharedSecret(clientPrivate, serverPublic)
val exp2 = Ladder.sharedSecret(clientPrivate, idPublic)
if (isZero(exp1) || isZero(exp2)) return null
return common(exp1 + exp2, nodeId, idPublic, clientPublic, serverPublic)
}
/** The server half. `null` means the handshake must be abandoned. */
fun serverHandshake(
clientPublic: ByteArray, serverPrivate: ByteArray, serverPublic: ByteArray,
idPrivate: ByteArray, idPublic: ByteArray, nodeId: ByteArray,
): Result? {
// EXP(X,y) | EXP(X,b)
val exp1 = Ladder.sharedSecret(serverPrivate, clientPublic)
val exp2 = Ladder.sharedSecret(idPrivate, clientPublic)
if (isZero(exp1) || isZero(exp2)) return null
return common(exp1 + exp2, nodeId, idPublic, clientPublic, serverPublic)
}
private fun common(
exponents: ByteArray, nodeId: ByteArray,
b: ByteArray, x: ByteArray, y: ByteArray,
): Result {
// 🔴 `b` twice. Not a typo faithfully reproduced by accident — it is the
// wire format.
val suffix = b + b + x + y + PROTO_ID + nodeId
val secretInput = exponents + suffix
val keySeed = hmac(T_KEY, secretInput)
val verify = hmac(T_VERIFY, secretInput)
val auth = hmac(T_MAC, verify + suffix + "Server".toByteArray())
return Result(keySeed, auth)
}
/**
* HKDF-SHA256 over KEY_SEED, for the framing layer's keys.
*
* ⚠️ The salt is `t_key` — the same string used as the HMAC key for
* KEY_SEED — and the info is `m_expand`. Swapping them produces key material
* that is perfectly random-looking and shared with nobody.
*/
fun kdf(keySeed: ByteArray, outputByteCount: Int): ByteArray {
val hkdf = HKDFBytesGenerator(SHA256Digest())
hkdf.init(HKDFParameters(keySeed, T_KEY, M_EXPAND))
return ByteArray(outputByteCount).also { hkdf.generateBytes(it, 0, it.size) }
}
/**
* 🔴 **Constant time, and it has to be.** `auth` arrives from the network
* and is compared against a locally computed value; a byte-at-a-time
* comparison that returns early hands an attacker a way to construct a
* valid-looking one a byte at a time.
*/
fun authMatches(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
}
/** A shared secret of all zeros means the peer sent a low-order point. */
private fun isZero(x: ByteArray): Boolean {
var acc = 0
for (b in x) acc = acc or b.toInt()
return acc == 0
}
private fun hmac(key: ByteArray, message: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256"))
doFinal(message)
}
}
@@ -0,0 +1,213 @@
package io.flamenet.obfs4
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketAddress
import java.security.SecureRandom
import javax.net.SocketFactory
/**
* A bridge, as it appears in a bridge line.
*
* ⚠️ Parsed from `cert=` rather than taking the two halves separately, because
* `cert=` is what an operator is actually handed. Splitting it here means the
* app never has to.
*/
data class Obfs4Bridge(
val host: String,
val port: Int,
val nodeId: ByteArray,
val identityPublic: ByteArray,
) {
init {
require(nodeId.size == 20) { "a node id is 20 bytes" }
require(identityPublic.size == 32) { "an identity key is 32 bytes" }
}
override fun equals(other: Any?) = other is Obfs4Bridge && host == other.host &&
port == other.port && nodeId.contentEquals(other.nodeId) &&
identityPublic.contentEquals(other.identityPublic)
override fun hashCode() = (host.hashCode() * 31 + port) * 31 + nodeId.contentHashCode()
companion object {
/**
* `cert=` is base64 of `nodeId ‖ identityPublic` with its trailing `==`
* stripped — lyrebird re-appends it, so this must too.
*/
fun fromCert(host: String, port: Int, cert: String): Obfs4Bridge {
val raw = java.util.Base64.getDecoder().decode(cert + "==")
require(raw.size == 52) { "a cert decodes to 52 bytes, not ${raw.size}" }
return Obfs4Bridge(host, port, raw.copyOfRange(0, 20), raw.copyOfRange(20, 52))
}
}
}
/**
* A `Socket` that speaks obfs4 to a bridge and plaintext to its caller.
*
* 🔴 **The handshake happens inside `connect`, before any stream is handed
* out.** OkHttp will start writing an HTTP request the moment it has a
* connected socket, so a handshake deferred to the first write would interleave
* with request bytes and desynchronise the frame decoder on the first request
* of every connection.
*
* ⚠️ **A rejected handshake looks like a hang, not an error.** obfs4 bridges do
* not answer what they cannot verify, so the read timeout below is what turns a
* wire-format problem into a diagnosable failure instead of a stuck app.
*/
class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() {
private var plane: Obfs4DataPlane? = null
private val random = SecureRandom()
private var pending = ByteArray(0)
override fun connect(endpoint: SocketAddress?, timeout: Int) {
// ⚠️ The endpoint OkHttp asks for is the RELAY's address, and it is
// deliberately ignored: the socket goes to the bridge, which proxies on.
// Connecting to the endpoint here would defeat the entire transport.
super.connect(InetSocketAddress(bridge.host, bridge.port), timeout)
soTimeout = if (timeout > 0) timeout else HANDSHAKE_TIMEOUT_MILLIS
performHandshake()
}
private fun performHandshake() {
var priv: ByteArray; var pub: ByteArray; var repr: ByteArray
while (true) {
// 🔴 Fresh entropy each attempt, never a re-tweak of a key that had
// no representative: representability is a property of the key, so
// retrying the same one differently would bias which keys are used.
val candidate = ByteArray(32).also { random.nextBytes(it) }
val p = Ladder.dirtyPublicKey(candidate)
val r = Elligator2.representativeForPublicKey(p, random.nextInt(256))
if (r != null) { priv = candidate; pub = p; repr = r; break }
}
val handshake = Obfs4ClientHandshake(
bridge.nodeId, bridge.identityPublic, priv, pub, repr,
Obfs4Handshake.currentEpochHour())
// ⚠️ Padding length must come from a CSPRNG. A fixed or predictable
// handshake length is a fingerprint on its own, however well disguised
// the bytes inside it are.
val padLength = Obfs4Handshake.CLIENT_MIN_PAD_LENGTH +
random.nextInt(Obfs4Handshake.CLIENT_MAX_PAD_LENGTH -
Obfs4Handshake.CLIENT_MIN_PAD_LENGTH)
val padding = ByteArray(minOf(padLength, 1200)).also { random.nextBytes(it) }
val out = super.getOutputStream()
out.write(handshake.opening(padding))
out.flush()
val input = super.getInputStream()
val buf = ByteArray(16384)
var established: Obfs4ClientHandshake.Established? = null
for (attempt in 0 until 16) {
val n = input.read(buf)
if (n <= 0) throw Obfs4HandshakeRefused()
established = handshake.receive(buf.copyOfRange(0, n))
if (established != null) break
}
val session = established ?: throw Obfs4HandshakeRefused()
val keys = Obfs4Session.directionKeys(session.keySeed, isClient = true)
val dataPlane = Obfs4DataPlane(keys.first, keys.second)
// ⚠️ Whatever followed the handshake in the same read is already framed
// traffic — usually the bridge's PRNG seed. Dropping it desynchronises
// the frame decoder immediately.
pending = dataPlane.receive(session.leftover, isClient = true)
plane = dataPlane
}
override fun getOutputStream(): OutputStream {
val dataPlane = plane ?: throw IllegalStateException("not connected")
val raw = super.getOutputStream()
return object : OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(b: ByteArray, off: Int, len: Int) {
raw.write(dataPlane.send(b.copyOfRange(off, off + len)))
}
override fun flush() = raw.flush()
}
}
override fun getInputStream(): InputStream {
val dataPlane = plane ?: throw IllegalStateException("not connected")
val raw = super.getInputStream()
return object : InputStream() {
private var buffered = pending.also { pending = ByteArray(0) }
private var offset = 0
override fun read(): Int {
val one = ByteArray(1)
return if (read(one, 0, 1) == 1) one[0].toInt() and 0xff else -1
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
// ⚠️ Loops until a frame yields application bytes. A frame can
// decode to nothing at all — padding-only packets are cover
// traffic — and returning 0 there would look like end-of-stream
// to most callers.
while (offset >= buffered.size) {
val chunk = ByteArray(16384)
val n = raw.read(chunk)
if (n <= 0) return -1
buffered = dataPlane.receive(chunk.copyOfRange(0, n), isClient = true)
offset = 0
}
val n = minOf(len, buffered.size - offset)
System.arraycopy(buffered, offset, b, off, n)
offset += n
return n
}
override fun available(): Int = buffered.size - offset
}
}
companion object {
const val HANDSHAKE_TIMEOUT_MILLIS = 20_000
}
}
/**
* 🔴 The bridge said nothing, which is what a bridge does when it cannot verify
* a handshake — or when it is not there at all. The two are indistinguishable
* on purpose, so this message must not claim to know which.
*/
class Obfs4HandshakeRefused : java.io.IOException(
"the bridge did not complete a handshake. obfs4 bridges stay silent when a handshake " +
"is invalid, so this means either the bridge line is wrong or nothing is listening.")
/**
* Hands OkHttp obfs4 sockets.
*
* ⚠️ **Every overload must be implemented, not just the one OkHttp happens to
* call.** `SocketFactory` has four `createSocket` shapes; leaving any of them
* returning a plain socket means some code path silently bypasses the transport
* — the traffic still works, which is exactly why nobody would notice.
*/
class Obfs4SocketFactory(private val bridge: Obfs4Bridge) : SocketFactory() {
override fun createSocket(): Socket = Obfs4Socket(bridge)
override fun createSocket(host: String, port: Int): Socket =
Obfs4Socket(bridge).apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket =
Obfs4Socket(bridge).apply {
bind(InetSocketAddress(localHost, localPort))
connect(InetSocketAddress(host, port))
}
override fun createSocket(host: InetAddress, port: Int): Socket =
Obfs4Socket(bridge).apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int,
): Socket = Obfs4Socket(bridge).apply {
bind(InetSocketAddress(localAddress, localPort))
connect(InetSocketAddress(address, port))
}
}
@@ -0,0 +1,133 @@
package io.flamenet.obfs4
/**
* SipHash-2-4, and obfs4's length-obfuscation DRBG.
*
* ⚠️ **Hand-written despite BouncyCastle shipping a SipHash**, which is worth
* justifying because every other primitive in this module is BouncyCastle's.
* BC's `SipHash` is a `Mac`: `doFinal` **resets** the state. 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 entire history for every block, which is O(n²) in the number of frames on
* a connection. So the state machine is written out; it is fifty lines and
* there are vectors for it.
*/
class SipHash24(key: ByteArray) {
private var v0: Long
private var v1: Long
private var v2: Long
private var v3: Long
private var buffer = ByteArray(0)
private var totalBytes = 0
init {
require(key.size == 16) { "SipHash-2-4 takes a 16-byte key" }
val k0 = load64(key, 0)
val k1 = load64(key, 8)
v0 = k0 xor 0x736f6d6570736575uL.toLong()
v1 = k1 xor 0x646f72616e646f6duL.toLong()
v2 = k0 xor 0x6c7967656e657261uL.toLong()
v3 = k1 xor 0x7465646279746573uL.toLong()
}
private constructor(other: SipHash24) : this(ByteArray(16)) {
v0 = other.v0; v1 = other.v1; v2 = other.v2; v3 = other.v3
buffer = other.buffer.copyOf()
totalBytes = other.totalBytes
}
fun update(bytes: ByteArray) {
totalBytes += bytes.size
buffer += bytes
var offset = 0
while (buffer.size - offset >= 8) {
val m = load64(buffer, offset)
v3 = v3 xor m
round(); round()
v0 = v0 xor m
offset += 8
}
if (offset > 0) buffer = buffer.copyOfRange(offset, buffer.size)
}
/**
* The digest so far.
*
* 🔴 **Finalises a COPY.** The DRBG keeps writing after each read, so a
* `sum` that mutated the running state would produce a keystream correct
* for exactly one block — and the first frame of a connection would decode
* perfectly before everything after it failed.
*/
fun sum(): Long {
val c = SipHash24(this)
var b = (c.totalBytes.toLong() and 0xff) shl 56
for ((i, byte) in c.buffer.withIndex()) b = b or ((byte.toLong() and 0xff) shl (8 * i))
c.v3 = c.v3 xor b
c.round(); c.round()
c.v0 = c.v0 xor b
c.v2 = c.v2 xor 0xff
c.round(); c.round(); c.round(); c.round()
return c.v0 xor c.v1 xor c.v2 xor c.v3
}
/** ⚠️ Little-endian, matching the Go implementation obfs4 uses. */
fun sumBytes(): ByteArray {
val s = sum()
return ByteArray(8) { ((s ushr (8 * it)) and 0xff).toByte() }
}
private fun round() {
v0 += v1; v1 = java.lang.Long.rotateLeft(v1, 13); v1 = v1 xor v0
v0 = java.lang.Long.rotateLeft(v0, 32)
v2 += v3; v3 = java.lang.Long.rotateLeft(v3, 16); v3 = v3 xor v2
v0 += v3; v3 = java.lang.Long.rotateLeft(v3, 21); v3 = v3 xor v0
v2 += v1; v1 = java.lang.Long.rotateLeft(v1, 17); v1 = v1 xor v2
v2 = java.lang.Long.rotateLeft(v2, 32)
}
private fun load64(b: ByteArray, offset: Int): Long {
var v = 0L
for (i in 7 downTo 0) v = (v shl 8) or (b[offset + i].toLong() and 0xff)
return v
}
companion object {
fun hash(key: ByteArray, message: ByteArray): ByteArray =
SipHash24(key).also { it.update(message) }.sumBytes()
}
}
/**
* obfs4's length-obfuscation DRBG: SipHash-2-4 run in OFB.
*
* 🔴 **The reference's comment describes a different algorithm from its code.**
* The comment says `IV[n] = H(K, IV[n-1])` — a fresh hash of the previous block
* each time. The code never resets the hash, so block `n` is really
* `SipHash(K, IV[0] ‖ … ‖ IV[n-1])`, cumulative.
*
* The two agree on block **one** and diverge from block two. An implementation
* built from the comment decodes the first frame of a connection perfectly and
* then fails, which is about the least helpful symptom available.
*/
class LengthDrbg(seed: ByteArray) {
private val hash: SipHash24
private var ofb: ByteArray
init {
require(seed.size == 24) { "the DRBG seed is 24 bytes" }
hash = SipHash24(seed.copyOfRange(0, 16))
ofb = seed.copyOfRange(16, 24)
}
fun nextBlock(): ByteArray {
hash.update(ofb)
ofb = hash.sumBytes()
return ofb
}
/** The two bytes a frame length is masked with — the **big-endian** head. */
fun nextLengthMask(): Int {
val block = nextBlock()
return ((block[0].toInt() and 0xff) shl 8) or (block[1].toInt() and 0xff)
}
}
@@ -0,0 +1,279 @@
package io.flamenet.obfs4
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Go's RNG semantics, the packet layer, the replay filter, and a whole session.
*
* ⚠️ **The session tests are self-consistency, a weaker guarantee than the rest
* of this module.** Everything below the connection layer is checked against
* lyrebird's own bytes; this layer is checked against itself, because
* `makePacket` is unexported and session keys are internal to a running
* connection. Two identically-wrong ends agree perfectly — the definitive check
* is a live handshake against a real bridge, which the Swift port has and this
* one does not yet.
*/
class ConnectionTest {
private fun randVectors(): JSONObject {
val s = javaClass.classLoader.getResourceAsStream("rand-vectors.json")
assertNotNull(s, "rand-vectors.json is missing")
return JSONObject(s.readBytes().decodeToString())
}
private fun hex(s: String) = ByteArray(s.length / 2) {
s.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
// MARK: - Go RNG parity
@Test
fun `int63 matches go`() {
val v = randVectors().getJSONArray("rand")
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val rng = GoRandom(hex(o.getString("seed")))
val want = o.getJSONArray("int63")
for (j in 0 until want.length()) {
assertEquals(want.getString(j), rng.int63().toString(), "draw $j")
}
}
}
/**
* ⚠️ `intn(100)` takes the rejection-sampling branch and `intn(1)` the
* power-of-two mask branch. They consume different amounts of randomness, so
* both are covered.
*/
@Test
fun `intn matches go on both branches`() {
val v = randVectors().getJSONArray("rand")
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
for (field in listOf("intn_100" to 100, "intn_1" to 1)) {
val rng = GoRandom(hex(o.getString("seed")))
val want = o.getJSONArray(field.first)
for (j in 0 until want.length()) {
assertEquals(want.getInt(j), rng.intn(field.second), "${field.first} draw $j")
}
}
}
}
/**
* 🔴 Go divides `Int63` by 2^63 rather than the cleaner `Int63n(1<<53)`
* form, deliberately, to preserve its value stream. Bit equality is
* asserted: a "close enough" double here is a different padding length
* later.
*/
@Test
fun `float64 matches go exactly`() {
val v = randVectors().getJSONArray("rand")
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val rng = GoRandom(hex(o.getString("seed")))
val want = o.getJSONArray("float64")
for (j in 0 until want.length()) {
assertEquals(want.getDouble(j).toRawBits(), rng.float64().toRawBits(),
"draw $j differs in its bits")
}
}
}
/** ⚠️ Including Go's useless first swap, which consumes a draw. */
@Test
fun `perm matches go`() {
val v = randVectors().getJSONArray("rand")
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val rng = GoRandom(hex(o.getString("seed")))
val want = o.getJSONArray("perm_10")
val got = rng.perm(10)
for (j in 0 until want.length()) assertEquals(want.getInt(j), got[j], "perm[$j]")
}
}
/** The whole derivation, against the reference's own rendering of it. */
@Test
fun `the length distribution matches lyrebird`() {
val v = randVectors().getJSONArray("dist")
val mismatches = mutableListOf<String>()
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val d = WeightedDist(hex(o.getString("seed")), o.getInt("min"), o.getInt("max"),
o.getBoolean("biased"))
val sb = StringBuilder("[ ")
for (j in d.values.indices) {
if (d.weights[j] > 0.01) sb.append("%d: %f ".format(d.values[j], d.weights[j]))
}
sb.append("]")
if (sb.toString() != o.getString("string")) {
mismatches += "biased=${o.getBoolean("biased")}\n want ${o.getString("string")}\n got $sb"
}
}
assertTrue(mismatches.isEmpty(),
"${mismatches.size} of ${v.length()} distributions differed.\n" +
mismatches.take(2).joinToString("\n"))
}
// MARK: - Packets
@Test
fun `packets round trip at every interesting length`() {
for (payloadLen in listOf(0, 1, 100, Obfs4Packet.MAX_PAYLOAD_LENGTH)) {
for (padding in listOf(0, 1, 50)) {
if (payloadLen + padding > Obfs4Packet.MAX_PAYLOAD_LENGTH) continue
val payload = ByteArray(payloadLen) { it.toByte() }
val encoded = Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, payload, padding)
assertEquals(Obfs4Packet.OVERHEAD + payloadLen + padding, encoded.size)
val (type, decoded) = Obfs4Packet.decode(encoded)
assertEquals(Obfs4Packet.TYPE_PAYLOAD, type)
assertContentEquals(payload, decoded, "padding $padding leaked into the payload")
}
}
}
/**
* 🔴 A declared length longer than the packet is the classic parser bug,
* handed to you by whoever is on the other end. In the Swift port, removing
* this guard turned one malformed packet into a process kill.
*/
@Test
fun `a declared length longer than the packet is rejected`() {
val packet = Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, byteArrayOf(1, 2, 3), 0)
packet[1] = 0xff.toByte(); packet[2] = 0xff.toByte()
assertFailsWith<Obfs4Packet.DeclaredLengthExceedsPacket> { Obfs4Packet.decode(packet) }
assertFailsWith<Obfs4Packet.TooShort> { Obfs4Packet.decode(byteArrayOf(0, 0)) }
}
/** ⚠️ Unknown types are ignored so the protocol can grow. */
@Test
fun `unknown packet types are ignored rather than rejected`() {
val packet = Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, byteArrayOf(9, 9), 0)
packet[0] = 0x7f
val (type, payload) = Obfs4Packet.decode(packet)
assertNull(type, "an unknown type must decode to null, not throw")
assertContentEquals(byteArrayOf(9, 9), payload)
}
// MARK: - Replay
@Test
fun `the replay filter rejects a second sighting`() {
val filter = ReplayFilter()
val mac = ByteArray(16) { 0xab.toByte() }
assertFalse(filter.testAndSet(mac), "the first sighting must pass")
assertTrue(filter.testAndSet(mac), "the second must be rejected")
val other = mac.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() }
assertFalse(filter.testAndSet(other), "a different MAC is not a replay")
}
@Test
fun `replay entries expire`() {
val filter = ReplayFilter(lifetimeMillis = 60_000)
val mac = ByteArray(16) { 1 }
assertFalse(filter.testAndSet(mac, 0))
assertTrue(filter.testAndSet(mac, 30_000))
assertFalse(filter.testAndSet(mac, 120_000), "an expired entry must not block forever")
}
// MARK: - A whole session
/**
* 🔴 **The key halves swap by role.** Two ends that both take the first half
* each work perfectly alone and cannot hear each other.
*/
@Test
fun `the direction keys are mirrored`() {
val seed = ByteArray(32) { it.toByte() }
val c = Obfs4Session.directionKeys(seed, true)
val s = Obfs4Session.directionKeys(seed, false)
assertContentEquals(c.first, s.second)
assertContentEquals(c.second, s.first)
assertFalse(c.first.contentEquals(c.second))
}
private fun pair(): Pair<Obfs4DataPlane, Obfs4DataPlane> {
val seed = ByteArray(32) { (it * 7 + 3).toByte() }
val c = Obfs4Session.directionKeys(seed, true)
val s = Obfs4Session.directionKeys(seed, false)
return Obfs4DataPlane(c.first, c.second) to Obfs4DataPlane(s.first, s.second)
}
@Test
fun `data flows both ways through padded packets`() {
val (client, server) = pair()
val hello = "hello from the client".toByteArray()
val wire = client.send(hello, 137)
assertTrue(wire.size > hello.size + 137, "padding must actually reach the wire")
assertContentEquals(hello, server.receive(wire, isClient = false))
val reply = "and back again".toByteArray()
assertContentEquals(reply, client.receive(server.send(reply)))
}
/** ⚠️ A write larger than one packet must span several, not be truncated. */
@Test
fun `large writes span multiple packets`() {
val (client, server) = pair()
val big = ByteArray(Obfs4Packet.MAX_PAYLOAD_LENGTH * 3 + 17) { it.toByte() }
assertContentEquals(big, server.receive(client.send(big), isClient = false))
}
/** Sockets deliver in arbitrary pieces; the result must not depend on how. */
@Test
fun `delivery in arbitrary chunks makes no difference`() {
val (client, server) = pair()
val message = ByteArray(5000) { (it * 31).toByte() }
val wire = client.send(message, 11)
var received = ByteArray(0)
var offset = 0
for (chunk in listOf(1, 7, 3, 1000, 64, 2, 9999)) {
if (offset >= wire.size) break
val n = minOf(chunk, wire.size - offset)
received += server.receive(wire.copyOfRange(offset, offset + n), isClient = false)
offset += n
}
while (offset < wire.size) {
received += server.receive(byteArrayOf(wire[offset]), isClient = false)
offset++
}
assertContentEquals(message, received)
}
/**
* 🔴 Only a client adopts a PRNG seed. A server that honoured one would let
* any peer dictate its padding.
*/
@Test
fun `only the client adopts a prng seed`() {
val (client, server) = pair()
val seed = ByteArray(24) { (it * 5 + 1).toByte() }
val fromServer = server.sendPrngSeed(seed)
assertContentEquals(ByteArray(0), client.receive(fromServer, isClient = true))
assertNotNull(client.lengthDist, "the client must adopt the seed")
val (c2, s2) = pair()
s2.receive(c2.sendPrngSeed(seed), isClient = false)
assertNull(s2.lengthDist, "a server must never adopt a peer's seed")
}
@Test
fun `padding-only traffic carries no data`() {
val (client, server) = pair()
val wire = client.sendPadding(500)
assertTrue(wire.size > 500)
assertContentEquals(ByteArray(0), server.receive(wire, isClient = false),
"cover traffic must not surface as application data")
}
}
@@ -0,0 +1,187 @@
package io.flamenet.obfs4
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Kotlin map, against the same vectors the Swift port uses.
*
* 🔴 **These are the only check that means anything here.** Elligator2 can be
* wrong without failing: a subtly incorrect map still produces 32 bytes, still
* completes a handshake, and is merely not quite uniform — which is exactly what
* a censor's entropy test looks for and exactly what no runtime symptom will
* reveal.
*
* ⚠️ **These vectors come from lyrebird, not from the Swift port.** Two
* independent implementations agreeing with the same *reference* is a real
* statement; two of our own implementations agreeing with each other is not,
* and would hide any mistake shared between them.
*/
class Elligator2Test {
private fun vectors(): JSONObject {
val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json")
assertNotNull(stream, "the vectors are missing from test resources")
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) }
@Test
fun `the direct map agrees with the reference`() {
val reverse = vectors().getJSONArray("reverse")
assertTrue(reverse.length() > 100, "too few vectors to mean anything")
val mismatches = mutableListOf<String>()
for (i in 0 until reverse.length()) {
val v = reverse.getJSONObject(i)
val got = Elligator2.publicKeyFromRepresentative(hex(v.getString("representative")))
val want = v.getString("public")
if (hex(got) != want) {
mismatches += "repr ${v.getString("representative")}\n want $want\n got ${hex(got)}"
}
}
assertTrue(
mismatches.isEmpty(),
"${mismatches.size} of ${reverse.length()} representatives mapped to the wrong " +
"public key.\n\n${mismatches.take(3).joinToString("\n")}",
)
}
/** ⚠️ A map returning a constant would pass a careless equality test. */
@Test
fun `the map is not degenerate`() {
val reverse = vectors().getJSONArray("reverse")
val outputs = (0 until minOf(64, reverse.length())).map {
hex(
Elligator2.publicKeyFromRepresentative(
hex(reverse.getJSONObject(it).getString("representative"))
)
)
}.toSet()
assertTrue(outputs.size > 60, "the map is collapsing distinct inputs onto one output")
}
@Test
fun `every reference key is representable for both tweak parities`() {
val reverse = vectors().getJSONArray("reverse")
val failures = mutableListOf<String>()
for (i in 0 until reverse.length()) {
val v = reverse.getJSONObject(i)
for (tweak in listOf(0, 1)) {
if (Elligator2.representativeForPublicKey(hex(v.getString("public")), tweak) == null) {
failures += "${v.getString("public")} tweak=$tweak"
}
}
}
assertTrue(failures.isEmpty(), "${failures.size} reference keys had no representative")
}
/**
* 🔴 The test that pins the bytes rather than merely their validity. Both
* branches are legitimate representatives of the same key, so the reference's
* own answer must be one of the two we produce.
*/
@Test
fun `one tweak reproduces the reference representative exactly`() {
val reverse = vectors().getJSONArray("reverse")
val mismatches = mutableListOf<String>()
for (i in 0 until reverse.length()) {
val v = reverse.getJSONObject(i)
val candidates = listOf(0, 1).mapNotNull { tweak ->
Elligator2.representativeForPublicKey(hex(v.getString("public")), tweak)
?.also { it[31] = (it[31].toInt() and 0x3f).toByte() }
?.let { hex(it) }
}
val want = hex(v.getString("representative"))
want[31] = (want[31].toInt() and 0x3f).toByte()
if (hex(want) !in candidates) {
mismatches += "public ${v.getString("public")}\n want ${hex(want)}\n got $candidates"
}
}
assertTrue(
mismatches.isEmpty(),
"${mismatches.size} keys produced a representative lyrebird did not.\n\n" +
mismatches.take(2).joinToString("\n"),
)
}
/**
* 🔴 An implementation that never says no is broken: obfs4 relies on
* discarding unrepresentable keys, and without that the representatives are
* not uniform and nothing at runtime says so.
*/
@Test
fun `about half of all points have no representative`() {
// A fixed seed, so a failure is reproducible rather than a rumour.
var state = 0x9E3779B97F4A7C15uL
fun next(): ULong {
state += 0x9E3779B97F4A7C15uL
var z = state
z = (z xor (z shr 30)) * 0xBF58476D1CE4E5B9uL
z = (z xor (z shr 27)) * 0x94D049BB133111EBuL
return z xor (z shr 31)
}
val trials = 2048
var represented = 0
repeat(trials) {
val u = ByteArray(32)
for (word in 0 until 4) {
val v = next()
for (b in 0 until 8) u[word * 8 + b] = ((v shr (8 * b)) and 0xffuL).toByte()
}
if (Elligator2.representativeForPublicKey(u, 0) != null) represented++
}
val rate = represented.toDouble() / trials
assertTrue(rate > 0.42, "far too many points rejected — rate $rate")
assertTrue(rate < 0.58, "far too many points accepted — rate $rate")
}
@Test
fun `everything we produce maps back to the key it came from`() {
val reverse = vectors().getJSONArray("reverse")
val broken = mutableListOf<String>()
for (i in 0 until reverse.length()) {
val v = reverse.getJSONObject(i)
val pk = hex(v.getString("public"))
// ⚠️ Padding bits deliberately set: the direct map is responsible for
// masking them off, and this is the only test that would notice if
// it stopped.
for (tweak in listOf(0x00, 0x41, 0x80, 0xc1)) {
val repr = Elligator2.representativeForPublicKey(pk, tweak) ?: continue
if (!Elligator2.publicKeyFromRepresentative(repr).contentEquals(pk)) {
broken += "${v.getString("public")} tweak=$tweak"
}
}
}
assertTrue(broken.isEmpty(), "${broken.size} representatives did not map back")
}
@Test
fun `degenerate inputs have no representative`() {
assertNull(Elligator2.representativeForPublicKey(ByteArray(32), 0), "u = 0")
}
@Test
fun `it agrees with the Swift port, via the same reference`() {
// Not a comparison against Swift — a restatement that both are pinned to
// the same lyrebird output. Kept as a named test so the relationship is
// visible rather than folklore.
val keygen = vectors().getJSONArray("keygen")
assertTrue(keygen.length() >= 256, "the shared vector file changed shape")
assertEquals(
vectors().getJSONArray("reverse").length(),
keygen.length(),
"reverse and keygen must describe the same keypairs",
)
}
}
@@ -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))
}
}
@@ -0,0 +1,209 @@
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.assertTrue
/**
* The handshake messages, against handshakes captured off the wire.
*
* 🔴 **These vectors are recordings, not computed values.** The handshake
* contains random padding chosen inside lyrebird and a MAC over the current
* epoch hour, so there is nothing to ask a reference to compute. The generator
* runs a real lyrebird client against a real lyrebird server over a pipe,
* proves they round-trip actual traffic, and records what each side sent.
*
* ⚠️ **The padding is recoverable, which makes these a test of generation too.**
* It sits between a fixed-length prefix and the mark, so finding the mark
* recovers it — and rebuilding from the recovered representative, padding and
* hour must reproduce the recording byte for byte.
*/
class HandshakeTest {
private fun vectors(): JSONObject {
val stream = javaClass.classLoader.getResourceAsStream("handshake-vectors.json")
assertNotNull(stream, "the handshake 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) }
private fun key(o: JSONObject) =
Obfs4Handshake.macKey(hex(o.getString("server_public_key")), hex(o.getString("node_id")))
/**
* Feed bytes until the client handshake parses, the way a server reads a
* socket. ⚠️ The recording contains the client's later traffic too, so there
* is exactly one prefix length at which the tail check succeeds.
*/
private fun parseIncrementally(o: JSONObject): Pair<Obfs4Handshake.ParsedClient, Int>? {
val wire = hex(o.getString("client_to_server"))
for (n in Obfs4Handshake.CLIENT_MIN_HANDSHAKE_LENGTH..wire.size) {
try {
return Obfs4Handshake.parseClientMessage(
wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n
} catch (_: Exception) { /* keep reading */ }
}
return null
}
@Test
fun `the server side parses real client handshakes`() {
val v = vectors().getJSONArray("handshakes")
assertTrue(v.length() > 8, "too few handshakes")
val failures = mutableListOf<String>()
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val parsed = parseIncrementally(o)
if (parsed == null) { failures += "node ${o.getString("node_id").take(12)}…"; continue }
val (p, n) = parsed
assertEquals(n, p.consumed)
assertTrue(p.padding.size >= Obfs4Handshake.CLIENT_MIN_PAD_LENGTH &&
p.padding.size <= Obfs4Handshake.CLIENT_MAX_PAD_LENGTH,
"padding ${p.padding.size} outside the permitted range")
assertTrue(n < hex(o.getString("client_to_server")).size,
"the handshake swallowed the traffic that followed it")
}
assertTrue(failures.isEmpty(), "${failures.size} never parsed: $failures")
}
/**
* 🔴 The client must **substring-search**. The server sends framed data
* immediately after its handshake, so the mark is not at the tail — a tail
* check here fails against every real bridge, and these vectors contain
* exactly that trailing data.
*/
@Test
fun `the client side parses real server handshakes`() {
val v = vectors().getJSONArray("handshakes")
val failures = mutableListOf<String>()
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val wire = hex(o.getString("server_to_client"))
try {
val p = Obfs4Handshake.parseServerMessage(wire, key(o), o.getLong("epoch_hour"))
assertTrue(p.consumed < wire.size, "trailing frames consumed as handshake")
assertEquals(32, p.auth.size)
} catch (e: Exception) {
failures += "node ${o.getString("node_id").take(12)}…: $e"
}
}
assertTrue(failures.isEmpty(), "${failures.size} did not parse:\n" +
failures.take(3).joinToString("\n"))
}
/**
* 🔴 **The generation test.** Rebuilding from the values recovered by
* parsing must reproduce lyrebird's bytes exactly — proving the mark, the
* MAC, the field order and the decimal epoch encoding all match, not merely
* that parsing is self-consistent.
*/
@Test
fun `regenerating reproduces lyrebirds bytes`() {
val v = vectors().getJSONArray("handshakes")
var mismatches = 0
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val (p, n) = parseIncrementally(o) ?: continue
val rebuilt = Obfs4Handshake.clientMessage(p.representative, p.padding, key(o), p.epochHour)
if (!rebuilt.contentEquals(hex(o.getString("client_to_server")).copyOfRange(0, n))) {
mismatches++
}
}
assertEquals(0, mismatches, "$mismatches client handshakes did not regenerate")
}
@Test
fun `regenerating the server message reproduces lyrebirds bytes`() {
val v = vectors().getJSONArray("handshakes")
var mismatches = 0
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val wire = hex(o.getString("server_to_client"))
val p = Obfs4Handshake.parseServerMessage(wire, key(o), o.getLong("epoch_hour"))
val rebuilt = Obfs4Handshake.serverMessage(
p.representative, p.auth, p.padding, key(o), o.getLong("epoch_hour"))
if (!rebuilt.contentEquals(wire.copyOfRange(0, p.consumed))) mismatches++
}
assertEquals(0, mismatches, "$mismatches server handshakes did not regenerate")
}
/** The server accepts the hour either side of its own, and nothing further. */
@Test
fun `the server tolerates one hour of clock skew and no more`() {
val o = vectors().getJSONArray("handshakes").getJSONObject(0)
val (_, n) = parseIncrementally(o)!!
val msg = hex(o.getString("client_to_server")).copyOfRange(0, n)
for (offset in listOf(-1L, 0L, 1L)) {
Obfs4Handshake.parseClientMessage(msg, key(o), o.getLong("epoch_hour") + offset)
}
for (offset in listOf(-2L, 2L, 24L)) {
assertFailsWith<Obfs4Handshake.MacMismatch>("skew of $offset hours must be refused") {
Obfs4Handshake.parseClientMessage(msg, key(o), o.getLong("epoch_hour") + offset)
}
}
}
/**
* 🔴 A scanner without the bridge's public key cannot produce a valid mark.
* This is the whole of obfs4's probe resistance.
*/
@Test
fun `a wrong identity key never finds the mark`() {
val o = vectors().getJSONArray("handshakes").getJSONObject(0)
val wrong = hex(o.getString("server_public_key")).also { it[0] = (it[0].toInt() xor 1).toByte() }
val badKey = Obfs4Handshake.macKey(wrong, hex(o.getString("node_id")))
val wire = hex(o.getString("client_to_server"))
for (n in Obfs4Handshake.CLIENT_MIN_HANDSHAKE_LENGTH..minOf(wire.size, 900)) {
assertFailsWith<Exception> {
Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), badKey, o.getLong("epoch_hour"))
}
}
}
/**
* ⚠️ `NeedMoreData` and `InvalidHandshake` are different answers: the first
* keeps the connection open and silent, the second closes it. A lengthened
* buffer reads as unfinished, which is the silence probe resistance depends
* on.
*/
@Test
fun `trailing bytes read as an unfinished handshake, not a rejection`() {
val o = vectors().getJSONArray("handshakes").getJSONObject(0)
val (_, n) = parseIncrementally(o)!!
val valid = hex(o.getString("client_to_server")).copyOfRange(0, n)
for (extra in listOf(1, 2, 64, 500)) {
assertFailsWith<Obfs4Handshake.NeedMoreData>("$extra trailing bytes") {
Obfs4Handshake.parseClientMessage(valid + ByteArray(extra) { 0x41 },
key(o), o.getLong("epoch_hour"))
}
}
}
@Test
fun `short buffers ask for more rather than rejecting`() {
val o = vectors().getJSONArray("handshakes").getJSONObject(0)
assertFailsWith<Obfs4Handshake.NeedMoreData> {
Obfs4Handshake.parseClientMessage(
hex(o.getString("client_to_server")).copyOfRange(0, 10),
key(o), o.getLong("epoch_hour"))
}
}
/**
* The padding floor exists so a client handshake is never shorter than a
* server one — otherwise direction is readable from length alone.
*/
@Test
fun `the client padding floor covers the server handshake`() {
assertTrue(
Obfs4Handshake.CLIENT_MIN_HANDSHAKE_LENGTH + Obfs4Handshake.CLIENT_MIN_PAD_LENGTH >=
Obfs4Handshake.SERVER_MIN_HANDSHAKE_LENGTH + Obfs4Handshake.INLINE_SEED_FRAME_LENGTH)
}
}
@@ -0,0 +1,229 @@
package io.flamenet.obfs4
import java.io.File
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import java.security.SecureRandom
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Handshakes against a **real lyrebird bridge**, and a real lyrebird client
* against this one.
*
* 🔴 **These are the only tests here that prove interoperability.** Everything
* else compares against recorded reference output or against itself, and a
* Kotlin-to-Kotlin test cannot catch a mistake both ends share — two
* identically-wrong implementations agree perfectly. Here the counterparty is
* the Tor Project's own code, deciding for itself whether to answer.
*
* ⚠️ **A bug shows up as SILENCE, not an error.** obfs4 is probe-resistant: a
* bridge that dislikes a handshake says nothing at all. So every read has a
* timeout and a timeout is reported as *"the bridge refused to answer"* — the
* one diagnosis that is actually true — rather than as a network fault.
*
* ⚠️ **Both directions are needed.** Swapping the framing key halves by role is
* fatal for a server and leaves the client test green, because a client takes
* the first half either way. Only the reverse direction catches it.
*
* Run with `scripts/obfs4-interop.sh`, which builds and starts the counterparty.
*/
class InteropTest {
private val random = SecureRandom()
/**
* 🔴 **Never skips quietly.** A silent skip is how a suite reports success
* for a test that has not run in months. Absent configuration is a hard
* failure unless the environment explicitly opts out, which forces the
* decision to be visible in the runner rather than buried here.
*/
private fun env(name: String): String? {
System.getenv(name)?.let { return it }
if (System.getenv("OBFS4_INTEROP_OPTOUT") == "1") return null
throw AssertionError(
"$name is not set and OBFS4_INTEROP_OPTOUT=1 was not given either. " +
"Run scripts/obfs4-interop.sh, or opt out explicitly.")
}
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) }
private fun representableKeypair(): Triple<ByteArray, ByteArray, ByteArray> {
// 🔴 Retried with FRESH entropy, never by re-tweaking a key that had no
// representative: representability is a property of the key, so
// retrying the same one differently would bias which keys get used.
var attempts = 0
while (true) {
attempts++
val candidate = ByteArray(32).also { random.nextBytes(it) }
val pub = Ladder.dirtyPublicKey(candidate)
val tweak = random.nextInt(256)
val repr = Elligator2.representativeForPublicKey(pub, tweak)
if (repr != null) return Triple(candidate, pub, repr)
assertTrue(attempts < 100, "representable keys should arrive in a few tries")
}
}
private fun readSome(socket: Socket, stage: String): ByteArray {
val buf = ByteArray(16384)
val n = try { socket.getInputStream().read(buf) } catch (e: SocketTimeoutException) { -1 }
if (n <= 0) throw AssertionError(
"the bridge refused to answer during $stage. obfs4 bridges stay silent when a " +
"handshake is invalid, so this is what a wire-format bug looks like — not a " +
"network fault.")
return buf.copyOfRange(0, n)
}
// MARK: - Kotlin client → real lyrebird bridge
@Test
fun `a handshake and an echo against a real lyrebird bridge`() {
val paramsPath = env("OBFS4_INTEROP_PARAMS") ?: return
val params = JSONObject(File(paramsPath).readText())
val nodeId = hex(params.getString("node_id"))
val identity = hex(params.getString("public_key"))
val address = params.getString("address").split(":")
val (priv, pub, repr) = representableKeypair()
val handshake = Obfs4ClientHandshake(
nodeId, identity, priv, pub, repr, Obfs4Handshake.currentEpochHour())
val padLength = Obfs4Handshake.CLIENT_MIN_PAD_LENGTH + random.nextInt(500)
val padding = ByteArray(padLength).also { random.nextBytes(it) }
Socket().use { socket ->
socket.connect(InetSocketAddress(address[0], address[1].toInt()), 8000)
// ⚠️ Without a read timeout a rejected handshake hangs the suite
// forever, because the bridge's correct behaviour is to say nothing.
socket.soTimeout = 8000
socket.getOutputStream().write(handshake.opening(padding))
socket.getOutputStream().flush()
var established: Obfs4ClientHandshake.Established? = null
for (i in 0 until 8) {
established = handshake.receive(readSome(socket, "the server handshake"))
if (established != null) break
}
assertNotNull(established, "the bridge never completed its handshake")
val keys = Obfs4Session.directionKeys(established.keySeed, isClient = true)
val plane = Obfs4DataPlane(keys.first, keys.second)
// ⚠️ Whatever followed the handshake in the same read is already
// framed traffic — usually the bridge's PRNG seed. Dropping it
// desynchronises the frame decoder immediately.
plane.receive(established.leftover, isClient = true)
val message = "flamenet obfs4 kotlin interop".toByteArray()
socket.getOutputStream().write(plane.send(message, 97))
socket.getOutputStream().flush()
var echoed = ByteArray(0)
for (i in 0 until 8) {
if (echoed.size >= message.size) break
echoed += plane.receive(readSome(socket, "the echo"), isClient = true)
}
assertEquals(hex(message), hex(echoed),
"the bridge accepted the handshake but the data plane disagreed")
}
}
/**
* ⚠️ The mirror image: a handshake the bridge should refuse. It must go
* silent rather than answer, which is what probe resistance means.
*/
@Test
fun `a bridge ignores a handshake it cannot verify`() {
val paramsPath = env("OBFS4_INTEROP_PARAMS") ?: return
val params = JSONObject(File(paramsPath).readText())
val address = params.getString("address").split(":")
Socket().use { socket ->
socket.connect(InetSocketAddress(address[0], address[1].toInt()), 8000)
socket.soTimeout = 6000
socket.getOutputStream().write(ByteArray(700).also { random.nextBytes(it) })
socket.getOutputStream().flush()
val n = try { socket.getInputStream().read(ByteArray(1024)) }
catch (e: SocketTimeoutException) { -1 }
assertTrue(n <= 0,
"the bridge replied to an invalid handshake with $n bytes. A bridge that " +
"answers unverified clients is one a scanner can confirm exists.")
}
}
// MARK: - Real lyrebird client → Kotlin bridge
/**
* 🔴 **The only thing that can prove the server role.** The client test
* stays green through bugs that are fatal for a server.
*/
@Test
fun `a real lyrebird client accepts the kotlin bridge`() {
val clientPath = env("OBFS4_INTEROP_CLIENT") ?: return
val identityPrivate = ByteArray(32).also { random.nextBytes(it) }
// ⚠️ CLEAN, not dirty. Only session keys keep their cofactor, because
// only those travel as representatives.
val identityPublic = Ladder.publicKey(identityPrivate)
val nodeId = ByteArray(20).also { random.nextBytes(it) }
val (sessionPriv, sessionPub, sessionRepr) = representableKeypair()
val filter = ReplayFilter()
ServerSocket(0, 4, java.net.InetAddress.getByName("127.0.0.1")).use { listener ->
val cert = java.util.Base64.getEncoder()
.encodeToString(nodeId + identityPublic).dropLast(2)
val message = "kotlin bridge, real client"
val process = ProcessBuilder(
clientPath, "-address", "127.0.0.1:${listener.localPort}",
"-cert", cert, "-message", message,
).redirectErrorStream(true).start()
listener.accept().use { conn ->
conn.soTimeout = 15000
val handshake = Obfs4ServerHandshake(
nodeId, identityPrivate, identityPublic,
sessionPriv, sessionPub, sessionRepr, Obfs4Handshake.currentEpochHour())
val padding = ByteArray(random.nextInt(400)).also { random.nextBytes(it) }
var accepted: Obfs4ServerHandshake.Accepted? = null
for (i in 0 until 8) {
val chunk = readSome(conn, "the client handshake")
accepted = handshake.receive(chunk, padding, filter)
if (accepted != null) break
}
assertNotNull(accepted, "the Kotlin bridge never accepted the client's handshake")
conn.getOutputStream().write(accepted.reply)
val keys = Obfs4Session.directionKeys(accepted.keySeed, isClient = false)
val plane = Obfs4DataPlane(keys.first, keys.second)
// Faithful to the reference: a bridge's first frame hands the
// client a seed so both ends pad from the same distribution.
conn.getOutputStream().write(
plane.sendPrngSeed(ByteArray(24).also { random.nextBytes(it) }))
conn.getOutputStream().flush()
var echoed = 0
while (echoed < message.length) {
val payload = plane.receive(readSome(conn, "the client's traffic"),
isClient = false)
if (payload.isNotEmpty()) {
conn.getOutputStream().write(plane.send(payload, 63))
conn.getOutputStream().flush()
echoed += payload.size
}
}
assertEquals(message.length, echoed, "the bridge did not echo the whole message")
}
val output = process.inputStream.readBytes().decodeToString()
assertEquals(0, process.waitFor(), "lyrebird rejected the Kotlin bridge:\n$output")
}
}
}
@@ -0,0 +1,128 @@
package io.flamenet.obfs4
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Cofactor-dirty scalar base multiplication, against lyrebird.
*
* 🔴 **The wrong answer sits right next to the right one.** The clean X25519
* public key — what every library computes — is a perfectly good Curve25519
* key. It agrees on shared secrets. It round-trips. It is simply confined to
* the prime-order subgroup and therefore *distinguishable*, which defeats the
* entire purpose. Clamping the private key before the call is all it takes to
* get one, so this checks structure as well as bytes.
*/
class LadderTest {
private fun vectors(): JSONObject {
val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json")
assertNotNull(stream, "the vectors are missing from test resources")
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) }
/**
* ⚠️ The `private` field is the 32 bytes lyrebird actually fed to
* `ScalarBaseMult` — already past its SHA-512 pass, and *not* clamped.
* Clamping before this call would clear the cofactor and quietly turn this
* into the clean function.
*/
@Test
fun `the dirty public key matches lyrebird`() {
val keygen = vectors().getJSONArray("keygen")
assertTrue(keygen.length() > 100, "too few vectors to mean anything")
val mismatches = mutableListOf<String>()
for (i in 0 until keygen.length()) {
val v = keygen.getJSONObject(i)
val got = Ladder.dirtyPublicKey(hex(v.getString("private")))
if (hex(got) != v.getString("public")) {
mismatches += "private ${v.getString("private")}\n" +
" want ${v.getString("public")}\n got ${hex(got)}"
}
}
assertTrue(
mismatches.isEmpty(),
"${mismatches.size} of ${keygen.length()} private keys produced the wrong " +
"public key.\n\n${mismatches.take(2).joinToString("\n")}",
)
}
/**
* 🔴 The structural check. If the cofactor were being cleared this would be
* the clean key, so it must differ from the clean key exactly when the low
* three bits of the private key are non-zero, and match it exactly when
* they are not.
*/
@Test
fun `it differs from the clean key exactly when the cofactor is set`() {
val keygen = vectors().getJSONArray("keygen")
val wrong = mutableListOf<String>()
var withCofactor = 0
var without = 0
for (i in 0 until keygen.length()) {
val priv = hex(keygen.getJSONObject(i).getString("private"))
val dirty = Ladder.dirtyPublicKey(priv)
val clean = Ladder.publicKey(priv)
if ((priv[0].toInt() and 7) != 0) {
withCofactor++
if (dirty.contentEquals(clean)) wrong += "cofactor set but the key is clean"
} else {
without++
if (!dirty.contentEquals(clean)) wrong += "no cofactor but the keys differ"
}
}
assertTrue(wrong.isEmpty(), "${wrong.size} keys had the wrong relationship to the clean key")
// ⚠️ Both arms must actually occur, or the assertion above is vacuous
// for one of them.
assertTrue(withCofactor > 10, "no keys with a cofactor component")
assertTrue(without > 10, "no keys without a cofactor component")
}
@Test
fun `the whole keypair matches lyrebird end to end`() {
val keygen = vectors().getJSONArray("keygen")
var mismatches = 0
for (i in 0 until keygen.length()) {
val v = keygen.getJSONObject(i)
val pub = Ladder.dirtyPublicKey(hex(v.getString("private")))
val want = hex(v.getString("representative"))
.also { it[31] = (it[31].toInt() and 0x3f).toByte() }
val matched = listOf(0, 1).any { tweak ->
Elligator2.representativeForPublicKey(pub, tweak)
?.also { it[31] = (it[31].toInt() and 0x3f).toByte() }
?.contentEquals(want) == true
}
if (!matched || hex(pub) != v.getString("public")) mismatches++
}
assertEquals(0, mismatches, "$mismatches keypairs did not match lyrebird end to end")
}
@Test
fun `trimScalar clamps the standard bits`() {
val all = ByteArray(32) { 0xff.toByte() }
val t = Ladder.trimScalar(all)
assertEquals(0xf8, t[0].toInt() and 0xff, "the low three bits must be cleared")
assertEquals(0x7f, t[31].toInt() and 0xff, "bit 255 cleared and bit 254 set")
assertEquals(0x40, Ladder.trimScalar(ByteArray(32))[31].toInt() and 0xff)
}
@Test
fun `adding the group order is driven by the low three bits`() {
val s = ByteArray(32) { 0x11 }
assertTrue(Ladder.addGroupOrderTimes(0xf8, s).contentEquals(s),
"low three bits clear must leave the scalar alone")
for (x in 1..7) {
assertTrue(!Ladder.addGroupOrderTimes(x, s).contentEquals(s), "x=$x must change it")
}
}
}
@@ -0,0 +1,149 @@
package io.flamenet.obfs4
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The ntor handshake, against lyrebird, from both sides.
*
* 🔴 **This is the suite that decides whether the port can talk to a real
* bridge.** Everything below it can be right while this is wrong: the maps
* round-trip, the keys are correct, and the handshake still derives a KEY_SEED
* no bridge in the world shares. obfs4 orders its transcript
* `B | B | X | Y | PROTOID | ID` — `B` twice — while the comment immediately
* above it in the reference claims `ID | B | Y | X | PROTOID`. Both orderings
* produce a well-formed 32-byte result. Only one interoperates.
*/
class NtorTest {
private fun vectors(): JSONObject {
val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json")
assertNotNull(stream, "the vectors are missing from test resources")
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) }
@Test
fun `the client handshake matches lyrebird`() {
val v = vectors().getJSONArray("ntor")
assertTrue(v.length() > 30, "too few handshakes to mean anything")
val mismatches = mutableListOf<String>()
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val r = Ntor.clientHandshake(
hex(o.getString("client_private")), hex(o.getString("client_public")),
hex(o.getString("server_public")), hex(o.getString("id_public")),
hex(o.getString("node_id")),
)
if (r == null) { mismatches += "node ${o.getString("node_id")}: refused"; continue }
if (hex(r.keySeed) != o.getString("key_seed")) {
mismatches += "node ${o.getString("node_id")} KEY_SEED\n" +
" want ${o.getString("key_seed")}\n got ${hex(r.keySeed)}"
}
if (hex(r.auth) != o.getString("auth")) mismatches += "AUTH mismatch"
}
assertTrue(mismatches.isEmpty(),
"${mismatches.size} of ${v.length()} client handshakes disagreed:\n" +
mismatches.take(2).joinToString("\n"))
}
@Test
fun `the server handshake matches lyrebird`() {
val v = vectors().getJSONArray("ntor")
var mismatches = 0
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val r = Ntor.serverHandshake(
hex(o.getString("client_public")),
hex(o.getString("server_private")), hex(o.getString("server_public")),
hex(o.getString("id_private")), hex(o.getString("id_public")),
hex(o.getString("node_id")),
)
if (r == null || hex(r.keySeed) != o.getString("key_seed") ||
hex(r.auth) != o.getString("auth")
) mismatches++
}
assertEquals(0, mismatches, "$mismatches server handshakes disagreed with lyrebird")
}
/**
* ⚠️ Both sides agreeing with each other is **not** the same as either being
* right — two identically-wrong implementations agree perfectly. This runs
* alongside the reference comparisons, never instead of them.
*/
@Test
fun `the two sides agree with each other`() {
val v = vectors().getJSONArray("ntor")
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val c = Ntor.clientHandshake(
hex(o.getString("client_private")), hex(o.getString("client_public")),
hex(o.getString("server_public")), hex(o.getString("id_public")),
hex(o.getString("node_id")))
val s = Ntor.serverHandshake(
hex(o.getString("client_public")),
hex(o.getString("server_private")), hex(o.getString("server_public")),
hex(o.getString("id_private")), hex(o.getString("id_public")),
hex(o.getString("node_id")))
assertNotNull(c); assertEquals(c, s, "the two sides disagreed")
}
}
@Test
fun `the kdf matches lyrebird`() {
val v = vectors().getJSONArray("ntor")
var mismatches = 0
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
if (hex(Ntor.kdf(hex(o.getString("key_seed")), 72)) != o.getString("okm")) mismatches++
}
assertEquals(0, mismatches, "$mismatches KDF outputs disagreed with lyrebird")
}
/**
* 🔴 A peer that sends a low-order point drives every shared secret to zero.
* Keying from that is keying from a value the attacker chose.
*/
@Test
fun `low order points are rejected`() {
val o = vectors().getJSONArray("ntor").getJSONObject(0)
val identity = ByteArray(32)
assertNull(Ntor.clientHandshake(
hex(o.getString("client_private")), hex(o.getString("client_public")),
identity, hex(o.getString("id_public")), hex(o.getString("node_id"))),
"a zero server key must be refused")
assertNull(Ntor.serverHandshake(
identity, hex(o.getString("server_private")), hex(o.getString("server_public")),
hex(o.getString("id_private")), hex(o.getString("id_public")),
hex(o.getString("node_id"))),
"a zero client key must be refused")
}
/**
* ⚠️ `clientPublic` and `nodeId` do not affect the shared secrets at all —
* they enter only through the transcript. If the suffix were built wrongly
* they could vanish without any other test noticing.
*/
@Test
fun `every input is bound into the transcript`() {
val o = vectors().getJSONArray("ntor").getJSONObject(0)
fun run(pub: ByteArray, id: ByteArray) = Ntor.clientHandshake(
hex(o.getString("client_private")), pub,
hex(o.getString("server_public")), hex(o.getString("id_public")), id)
val base = run(hex(o.getString("client_public")), hex(o.getString("node_id")))
val otherPub = hex(o.getString("client_public")).also { it[0] = (it[0].toInt() xor 1).toByte() }
val otherId = hex(o.getString("node_id")).also { it[0] = (it[0].toInt() xor 1).toByte() }
assertFalse(base == run(otherPub, hex(o.getString("node_id"))), "X is not bound in")
assertFalse(base == run(hex(o.getString("client_public")), otherId), "node ID is not bound in")
}
}
@@ -0,0 +1,107 @@
package io.flamenet.obfs4
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* SipHash-2-4 and the DRBG, against the same vectors the Swift port uses.
*
* 🔴 **Hand-written crypto without vectors is indefensible.** A subtly wrong
* hash produces well-distributed output that passes every statistical glance
* and matches nobody.
*/
class SipHashTest {
private fun vectors(): JSONObject {
val stream = javaClass.classLoader.getResourceAsStream("framing-vectors.json")
assertNotNull(stream, "the framing vectors are missing from test resources")
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) }
/**
* ⚠️ The vector lengths straddle the 8-byte block boundary and the final
* partial block on purpose — that is where a padding mistake hides, and a
* suite of round-numbered lengths would miss it entirely.
*/
@Test
fun `siphash matches the reference`() {
val v = vectors().getJSONArray("siphash")
assertTrue(v.length() > 8, "too few lengths covered")
val mismatches = mutableListOf<String>()
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val got = SipHash24.hash(hex(o.getString("key")), hex(o.getString("input")))
if (hex(got) != o.getString("sum")) {
mismatches += "len ${o.getString("input").length / 2}: " +
"want ${o.getString("sum")} got ${hex(got)}"
}
}
assertTrue(mismatches.isEmpty(),
"${mismatches.size} of ${v.length()} results wrong:\n" +
mismatches.take(4).joinToString("\n"))
}
@Test
fun `feeding a message in pieces equals feeding it whole`() {
val v = vectors().getJSONArray("siphash")
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val msg = hex(o.getString("input"))
if (msg.isEmpty()) continue
val h = SipHash24(hex(o.getString("key")))
for (b in msg) h.update(byteArrayOf(b))
assertEquals(o.getString("sum"), hex(h.sumBytes()),
"byte-at-a-time disagreed for a ${msg.size}-byte input")
}
}
/**
* 🔴 **The test that catches the reference's own documentation.** obfs4's
* comment says each block is a fresh `H(K, previous)`; the code never resets
* the hash, so blocks are cumulative. The two agree on block one and diverge
* afterwards, so ten blocks are checked, not one.
*/
@Test
fun `the drbg is cumulative not reset`() {
val v = vectors().getJSONArray("drbg")
assertTrue(v.getJSONObject(0).getJSONArray("blocks").length() > 5,
"one block would not distinguish the two readings")
val mismatches = mutableListOf<String>()
for (i in 0 until v.length()) {
val o = v.getJSONObject(i)
val drbg = LengthDrbg(hex(o.getString("seed")))
val blocks = o.getJSONArray("blocks")
for (j in 0 until blocks.length()) {
val got = hex(drbg.nextBlock())
if (got != blocks.getString(j)) {
mismatches += "seed ${o.getString("seed").take(16)}… block $j: " +
"want ${blocks.getString(j)} got $got"
break
}
}
}
assertTrue(mismatches.isEmpty(),
"${mismatches.size} DRBG streams diverged. A failure at block 0 means " +
"SipHash is wrong; at block 1 the hash is being reset between blocks.\n" +
mismatches.take(3).joinToString("\n"))
}
@Test
fun `the length mask is the big-endian head of the block`() {
val o = vectors().getJSONArray("drbg").getJSONObject(0)
val a = LengthDrbg(hex(o.getString("seed")))
val b = LengthDrbg(hex(o.getString("seed")))
val block = a.nextBlock()
assertEquals(((block[0].toInt() and 0xff) shl 8) or (block[1].toInt() and 0xff),
b.nextLengthMask())
}
}
@@ -0,0 +1,191 @@
package io.flamenet.obfs4
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.SecureRandom
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* A real HTTP request carried inside obfs4, end to end.
*
* ⚠️ **This tests the PLUMBING, not the wire format.** The protocol is already
* pinned to real lyrebird in both directions by `InteropTest`; what is unproven
* until here is the `SocketFactory` and stream wiring — that the handshake
* happens before any stream is handed out, that the bytes arriving with the
* handshake are not dropped, and that a frame decoding to nothing does not look
* like end-of-stream. Those are the places to lose data without producing a
* wrong byte anywhere.
*
* So both ends here are ours, and that is deliberate rather than a shortcut.
*/
class SocketFactoryTest {
private val random = SecureRandom()
private fun representableKeypair(): Triple<ByteArray, ByteArray, ByteArray> {
while (true) {
val candidate = ByteArray(32).also { random.nextBytes(it) }
val pub = Ladder.dirtyPublicKey(candidate)
val repr = Elligator2.representativeForPublicKey(pub, random.nextInt(256))
if (repr != null) return Triple(candidate, pub, repr)
}
}
/** A minimal obfs4 bridge that proxies plaintext to `upstreamPort`. */
private fun startBridge(upstreamPort: Int): Triple<Obfs4Bridge, ServerSocket, Thread> {
val identityPrivate = ByteArray(32).also { random.nextBytes(it) }
val identityPublic = Ladder.publicKey(identityPrivate)
val nodeId = ByteArray(20).also { random.nextBytes(it) }
val listener = ServerSocket(0, 8, java.net.InetAddress.getByName("127.0.0.1"))
val filter = ReplayFilter()
val worker = thread(isDaemon = true) {
while (!listener.isClosed) {
val conn = try { listener.accept() } catch (e: Exception) { return@thread }
thread(isDaemon = true) { serve(conn, nodeId, identityPrivate, identityPublic, filter, upstreamPort) }
}
}
return Triple(Obfs4Bridge("127.0.0.1", listener.localPort, nodeId, identityPublic),
listener, worker)
}
private fun serve(
conn: Socket, nodeId: ByteArray, identityPrivate: ByteArray, identityPublic: ByteArray,
filter: ReplayFilter, upstreamPort: Int,
) {
conn.use {
conn.soTimeout = 15000
val (sessionPriv, sessionPub, sessionRepr) = representableKeypair()
val handshake = Obfs4ServerHandshake(
nodeId, identityPrivate, identityPublic,
sessionPriv, sessionPub, sessionRepr, Obfs4Handshake.currentEpochHour())
val buf = ByteArray(16384)
var accepted: Obfs4ServerHandshake.Accepted? = null
for (i in 0 until 16) {
val n = conn.getInputStream().read(buf)
if (n <= 0) return
accepted = handshake.receive(buf.copyOfRange(0, n), ByteArray(0), filter)
if (accepted != null) break
}
val session = accepted ?: return // 🔴 Silent. No reply, no close reason.
conn.getOutputStream().write(session.reply)
conn.getOutputStream().flush()
val keys = Obfs4Session.directionKeys(session.keySeed, isClient = false)
val plane = Obfs4DataPlane(keys.first, keys.second)
Socket("127.0.0.1", upstreamPort).use { upstream ->
thread(isDaemon = true) {
val up = ByteArray(16384)
try {
while (true) {
val n = upstream.getInputStream().read(up)
if (n <= 0) break
conn.getOutputStream().write(plane.send(up.copyOfRange(0, n)))
conn.getOutputStream().flush()
}
} catch (_: Exception) { }
try { conn.close() } catch (_: Exception) { }
}
try {
while (true) {
val n = conn.getInputStream().read(buf)
if (n <= 0) break
val payload = plane.receive(buf.copyOfRange(0, n), isClient = false)
if (payload.isNotEmpty()) {
upstream.getOutputStream().write(payload)
upstream.getOutputStream().flush()
}
}
} catch (_: Exception) { }
}
}
}
@Test
fun `an http request travels through obfs4 end to end`() {
val body = "hello from behind the bridge"
val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
http.createContext("/probe") { exchange ->
val bytes = body.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
http.start()
val (bridge, listener, _) = startBridge(http.address.port)
try {
val client = okhttp3.OkHttpClient.Builder()
.socketFactory(Obfs4SocketFactory(bridge))
.callTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
// ⚠️ The URL is the RELAY's address. The socket factory ignores it
// and dials the bridge — which is the whole point, and the thing
// that would silently not happen if the factory were bypassed.
val request = okhttp3.Request.Builder()
.url("http://127.0.0.1:${http.address.port}/probe")
.build()
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals(body, response.body?.string())
}
} finally {
listener.close()
http.stop(0)
}
}
/**
* 🔴 A wrong bridge key must fail as a refusal, not hang forever. obfs4
* bridges stay silent for anyone they cannot verify, so without a timeout
* this is an app that never finishes a request.
*/
@Test
fun `a wrong bridge key fails rather than hanging`() {
val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
http.createContext("/probe") { it.sendResponseHeaders(200, -1); it.close() }
http.start()
val (bridge, listener, _) = startBridge(http.address.port)
try {
val wrong = bridge.copy(
identityPublic = bridge.identityPublic.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() })
val socket = Obfs4Socket(wrong)
socket.soTimeout = 4000
try {
socket.connect(InetSocketAddress("127.0.0.1", http.address.port), 4000)
fail("a wrong bridge key must not produce a usable socket")
} catch (e: Exception) {
assertTrue(e is Obfs4HandshakeRefused || e is java.io.IOException,
"expected a refusal, got ${e::class.simpleName}: ${e.message}")
} finally { socket.close() }
} finally {
listener.close()
http.stop(0)
}
}
/** Every `createSocket` overload must produce an obfs4 socket. */
@Test
fun `no socket factory overload silently bypasses the transport`() {
val bridge = Obfs4Bridge("127.0.0.1", 1, ByteArray(20), ByteArray(32) { 1 })
assertTrue(Obfs4SocketFactory(bridge).createSocket() is Obfs4Socket)
}
@Test
fun `a bridge line cert round trips`() {
val nodeId = ByteArray(20) { it.toByte() }
val key = ByteArray(32) { (it * 3).toByte() }
val cert = java.util.Base64.getEncoder().encodeToString(nodeId + key).dropLast(2)
val parsed = Obfs4Bridge.fromCert("example.org", 443, cert)
assertTrue(parsed.nodeId.contentEquals(nodeId))
assertTrue(parsed.identityPublic.contentEquals(key))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,482 @@
{
"note": "obfs4 framing: SipHash-2-4 length obfuscation over a cumulative OFB DRBG, and XSalsa20-Poly1305 secretbox frames with a 16-byte nonce prefix and a big-endian counter starting at 1.",
"siphash": [
{
"key": "b04d16fdfe8574916b589ba986d39ead",
"input": "",
"sum": "f02cd7391a4e8482"
},
{
"key": "80505c71c137d1b13c011d182a4860ae",
"input": "ec",
"sum": "e8b411ea3dc8187e"
},
{
"key": "500baa04b52ca9b9af0cdd912893bc79",
"input": "93544421dd5989",
"sum": "49ea85824bf537f1"
},
{
"key": "dc7f09648a17ab77e83fc01a107ce009",
"input": "83ae9aabb12e2379",
"sum": "86fa1a2909bae3fd"
},
{
"key": "55965420738db0ca2d1c6cdf1b2787f0",
"input": "5af9ebbfa59fc44d57",
"sum": "4b9a983a5d2b563d"
},
{
"key": "6475728d56fe9de5b41a3f88f8ff8dde",
"input": "1261e27b1dee4aad4c6338e8c18d17",
"sum": "abac7dbd4a176cff"
},
{
"key": "2a5e548f3e0a41534da2a77e1c048a39",
"input": "915827e28ba42e3bcda151d7d2bf0bcb",
"sum": "4578cbdb5feb5307"
},
{
"key": "191c8f75c4a761c38e6547c4a26e0283",
"input": "6e23af10e67324847f1302c9ac3e966fc0ba85ae4c2e7768",
"sum": "898ce6ab7cd74be7"
},
{
"key": "869ebecf71213b881b9a7eea65848599",
"input": "cf790c47ea1cd265e2fbf329c20f28afa5b92581d685d09aeaba0fe4b47fc3",
"sum": "0aeee596a69535d5"
},
{
"key": "dfd5d0ed6be757ee3b8368c46b31f198",
"input": "2dc7384e2f5bd820e8ce5bbac2129c2cbcb932d46c96a986566a55cd1a8c3c2b",
"sum": "337f2b23f7628c76"
},
{
"key": "d4ead1a74e6ae967c3be63d1c8cc2eff",
"input": "37d98992d5c9ae73b581b65281af9adabe380f79d7cca70f8e71164cdf533acc3060448b3a84212f1e8de1f0bba34f2f33ec0217d25c87fd519c636b49e69a",
"sum": "db78c2910a580241"
},
{
"key": "e7bc535b23ff38074a0faa0a58741e51",
"input": "dfa09d4e66b0dd052b0d70a755f9ecafd044f0ca9d622f7bc0fe72d3b59edec0128900d17ea87239d361769088869762cbf5fe8f38262482888af66d296fdbcc",
"sum": "3e6bdc1e9981e8ff"
},
{
"key": "60a4bdb8d6a7885b5bd8ee9e5332ebcd",
"input": "7f293c0b08f1c59b4900f744845cc84ae1bafc2e471f13c860db5c30858e701929f3ef2a3e2739b5e6727963f0af52019bf284a3dc0bca30db57129cb9dfaab3424b7a6f19a66e9092424a9226f2a67a2db6cac4dfbccb63cb7ac36e78124da170227ccd",
"sum": "7a75a1a48be32043"
}
],
"drbg": [
{
"seed": "c2bb177169e97393a4f8b4ed39b36571aeb6a0e3c5685a37",
"blocks": [
"1858382ca8007088",
"0f868ecd25305e95",
"56125bcd3c463e56",
"556a39907830ce5f",
"4fa19e5f8bff421c",
"5f0cc7c0996435d2",
"1f45f4ec925c88c6",
"6031f6348fc192eb",
"949ea637b2a35c27",
"fe17c2321eb1dece"
]
},
{
"seed": "5444699588214860e72376e942ae36343e15971167f532d2",
"blocks": [
"a30c7c3c05f4a4cd",
"945cddd61fadc6d1",
"b156069b234c49e4",
"be8e0851f4a91359",
"7338737f32666114",
"a6e7e334376c35e3",
"b4fec45cef2413b5",
"3090f77fa693add0",
"4defec8b5e3020ef",
"f1145200589e2955"
]
},
{
"seed": "e4694ec41c4edf3785b14449182d45f1fded191f3ab8ed64",
"blocks": [
"63bbb59c7e756ad4",
"b2aa0864092f20e0",
"7950b4e107fa1538",
"564d8bda57d96558",
"7ec3275b39831fdb",
"98dc16c48eb053a1",
"368b64dc3b311d0d",
"cb8074e7ea143439",
"0c7376611ca0526f",
"ef991bdfa8f7a51f"
]
},
{
"seed": "7f1e4fd0cc6c447d599c88f0fe1f3ed4c2971848c5a33943",
"blocks": [
"70c82a01f9d2c06f",
"d8a4118b133cc89b",
"f178082d54f4a273",
"840cd4508eb0cff3",
"9e345a2235fb6b7d",
"06dd0d9115d4bb95",
"9bf9660a9b6de802",
"0d2df01c2af553de",
"95b0bc45c84faca8",
"91701eb3ab7c2d91"
]
},
{
"seed": "edffb3f762329e9c0230e92c58ab74acf9cb23965c9f0c40",
"blocks": [
"078402e230bfb824",
"81f22d1a523faf92",
"79c10e4a68dd7016",
"ba23a588fdd7f3fc",
"192a20c8459e0241",
"8a1178c75557aa79",
"de831fa959c19bfe",
"cbfabdaacac6d52c",
"ce7f1d437fa8eff3",
"10a92be1577e1c0f"
]
},
{
"seed": "218f956d48af9dea83488e5d7d27fc29a857abcfe3bd47f4",
"blocks": [
"6444f7d6134db250",
"d6fe1caedf5ca524",
"109197549ce47986",
"12a69c273094fdbf",
"93eafaafd15fae8e",
"030002330f1d7b4f",
"cd8a92a9a825fcb7",
"91d5c30b768957ee",
"4775844ba8176454",
"cdd8fa615b97c31d"
]
},
{
"seed": "c6e8ebc2043b1709cc9efe6e40b2280b6c5f165ebeb3f8c4",
"blocks": [
"5e1c7691a5bca6dc",
"5b057bb826fb6324",
"712ac5ef355db0cd",
"0db54d774f1f2443",
"5b2c7fecb70fdcd0",
"953db623e4df7d65",
"256def5d7aefaea9",
"4dfd1d55343f42d8",
"5c588894dc63f522",
"57af5ce9bb368118"
]
},
{
"seed": "cd8d572479c366f03f82dfc0c8dfbb3e54c15f5eeba182b9",
"blocks": [
"a7e7bdf0afee6702",
"f110e3ecf2972d7b",
"fd70263134d096c9",
"a5da0b8022576f46",
"1a6739865a458521",
"3ef303ccc0862983",
"525ef4290808f984",
"e7064e562906531e",
"88a448fa04c03891",
"86940dfbf2844ce9"
]
}
],
"poly1305": [
{
"key": "c99c4d45aeb83ac00956a058e1f549090c1af4c679476f7e364ef4e22bb5af29",
"message": "",
"tag": "0c1af4c679476f7e364ef4e22bb5af29"
},
{
"key": "6c8635e2d8f13f3159f0b36ec7ff1279eb87f800cadedf7c4847305cc9d22d54",
"message": "3f",
"tag": "8908abc1f43a8e0bf2c468ae1788daa2"
},
{
"key": "b8f54148ec84914986a9565ee2957cdb1b88790cb2bb47170e3f2d3c72413ed0",
"message": "788b79e8da618e7161a2d35b3a9acf",
"tag": "3907203f923df30cbefad414e9bec737"
},
{
"key": "cedb8daca962d4b269d6e2b656902f784892015bf7c0b242d720c4998165dd73",
"message": "86fec8aa6bf59f1243c52508949fd848",
"tag": "6cb07bf688756dfd122683acbf6aa86f"
},
{
"key": "582c808e90e0377de62e05812c590c364757098d89bb286d8749b85eab15dbda",
"message": "72c4cf624c840f34d133238672ccd88ac9",
"tag": "19e115071df579fce667c6b555d1cbae"
},
{
"key": "96d0da5ac4236ea72d0bc182a8c765eb5c4c8a06d4c8c43a1503fdf21c1b9686",
"message": "110052440b4e2241bc15b551d6b77d16f228160c320ea3aeabc2fdee853f84",
"tag": "cd96fcc849998e795ea9a123110b7b69"
},
{
"key": "488f3be85d5300a24238b4e97f77909fb48fc0cdf82cc2e1a8a6b7c85a621b56",
"message": "f6f388bc759ecd697fe8653233a14b3b626eb01abedb6ecc823141d2e3898e3f",
"tag": "2d1ece9c3da8b4976990572532871467"
},
{
"key": "836d602d6c0b73853f856115f71ba716302b6dc12ca8c46c7fece97f94246d8e",
"message": "4891a2dce53580a0ecf163c30f45805cb35f521c875e30ab04acda90609013339d",
"tag": "238e553f0283030ba5ed49c224cda458"
},
{
"key": "bddadd0b236a9b6ff2f88bc88ee45cfc6620061bb5430ac980bd9761df49ae0b",
"message": "f584f27e842181bc2859d2ad36518e4a2aec37bc1e9c780841f3d9be4d20f812c4e19fc8d7c0d70f0cf53f337e394b45e261f8db8c751f3ac45791c9987e9a50",
"tag": "5e37ce028894db20f62dbe4b05dfca3d"
},
{
"key": "fcb600af1e23646d62c11f0a202ce189e1304c776e2d45281b06c17c29f9f560",
"message": "9f9e87ce5f974a355fdcb1f10513e184d6ca8d85a39c6af01841de228c818780dfc1c154b7b3615c3a72d1d60b3e1a0216893348c13a09a76681283246e5a0fc6af8f5457cb885a8073f772a5ffe6e15e9b2d59986cf241180969a6688d31848a3aa7a803afef75bde7d54605dbe107876feeea16debf4b5750a938648573d19610031d060978c91b562ac9691cbf3213f029aa4da91a21b65bf86615ccff45fb781cd6c0f8a4304f35a2697951f143b70825a61ea15b5b8545931e88e25a9ab1a4e805a019dcd83c1730269642d6c8fba510078466c5b363d091ebe913bdf55fdd9264bbdf7c925cf97499d6133531066f0c0355ee16453c96447b6032030387f",
"tag": "26bf1026e7681784d799412bb21d287a"
}
],
"salsa20": [
{
"key": "a9ad4d14365558aeaf161a7ba1abc2688be3662c49b9b7fda435cd4a9b196d40",
"nonce": "2d55dd07df0b811a",
"plaintext": "00",
"keystream": "b4"
},
{
"key": "5e5feed54adef3867a93717286ed341b36dcdb45944f906087534f391624327c",
"nonce": "c427c67473649e76",
"plaintext": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"keystream": "8201a81a25404e9316da15bf79d4a0bcd81365ab362ecfa42820c474ecb57bee58041a4e9967e9e3126a1606c622443e4052bcc4b12a6152275385a42f0d1a"
},
{
"key": "c63117cc7b942743667b19c3547ca63a535a719a7ff10aba9375f9b8c78dd241",
"nonce": "9452a08de031b556",
"plaintext": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"keystream": "bb0d24f5feeaf70687b68b42ed0e33465b6d9bc202e92c768b564324320566d1208ddebf09bd1a9124d5d2d8e91d9c591c3404cbd702e1e11f76fa06e01680eb"
},
{
"key": "fc45f553a0ff3ec0d6fb9ffbe3e0c3488d64515d74360570ce22f183e46467dc",
"nonce": "636e2097bd0a1286",
"plaintext": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"keystream": "e6000166ef2a3950827d9d436f48a65c3104649bb5f71c000702500cd7a53d511d1c1f92aff9705ae3647f7e6a6ad05434915bb3a1729bca035fd2e037d31a5e61"
},
{
"key": "00c742ca2cf764add401739de82543a4dcc7979d8acf38c20c332b47e4e5b1aa",
"nonce": "139d1247c7874d79",
"plaintext": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"keystream": "489fe72a3a61e193ed3b440315dbf5732b42baa27b50c8967992a9ff36bf42a3c3a5887af58ea7390b8efba48aaf26ad9040a6f00f57f11d64a64102ea73b66c434c12276a48ffc1002d4576ddfa7de1aa120e617db3205e1d84133dc8b8109a8a1059b3eb0b7ace691b72318f0fba632c8e7813db4be831c69ed08c9ba25375"
},
{
"key": "9f42ecb226410c71cb753b876b01855145f6a30d4537b624f353b6c2c2f7bafe",
"nonce": "9b014069a43f52aa",
"plaintext": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"keystream": "df6244f4bd77acb854ae40f038bce806ff99d12fe66e8d7610fb6a3b917b6fb15ad5452bfa2570c0e224249c46de3f42bf4c3ab09b12989624e3a2a0dd568012d8dcdd6254ab56e7100262b87fe39be069bc76c0e3642ad0eeb481fd3007a989eb631d25bec2d9317a4dfa783dce15591c8adac8b62bb2c5d8d111817f7c84904b313ba955752a89b67c086f81f4c8de0a97b5699d6ff7d262fa22158c617463dc4a7e4d48eb192a5ab57d25f58dae2d2f9fb4a14f28d2afd166ecc50d79921f511ea4020c7c4790"
}
],
"secretbox": [
{
"key": "a4045bc0f78a65120c94c5bfface822d2e9368288424f874dc82acc8b4a6ece7",
"nonce": "ef2115eba19ce8eda25978d2eff1997fade0a47ed774475e",
"plaintext": "",
"box": "bde7b5f0c27316e5682efd3c2b6c03dd"
},
{
"key": "a5ea389ae2dd2e9b3308c4262256631e1593cf5a333c5aee9f952ef56c6bf02a",
"nonce": "25f024c8fc24a821ce094f860466894da18d442c4cda6181",
"plaintext": "ea",
"box": "b80d6a525c425cd5f65bcacbeccb19f653"
},
{
"key": "9aaa991e38b161c972f118a6832ab82d1b87d627d80b43f533ba754f7dbe8dcd",
"nonce": "1134953bb76363282bb05f91853be2f9b39c169b8f2e904e",
"plaintext": "301bc543b16b0cc4b981a5e40bf79c0fee62b847b62fc1fc6db30d076d82c2",
"box": "aa48b7cbf28ee3a5faf85b14935fd90d5c87e7118b205baa34574aadf9f4caf33a9b9e152f6096e094c9696147e667"
},
{
"key": "6da3709894d9883fff1b19d1808284b56f1c45ddbc07952d6b87fc0a09e964b9",
"nonce": "922582885f30744592e18da34f9fadc380a15af343d8f7ab",
"plaintext": "44e69316b0eb914a6cfbee78a3fdb0584897fb6220c31822e6178c673a8c9e84",
"box": "ea4f6f3224658184be54f81d107bfc220e9da56dd4d75aa4854b869464479059c86f6f63972cd475daaa5a97e48e1336"
},
{
"key": "dcce8e3de9b00d4d44f6d6324d4cf0eacaa1a797b5fe1693a1adfb23a3b45496",
"nonce": "594c125d3433b5bd55b1d678b56e2fddc70cc719df3cf6e9",
"plaintext": "26a2f5af169bb79e4570b07e5b4a0b6e1b3bdd56606788d3e4157d4958edd783a779a1861f5a51aba60def1d9fb802de353fbb8730217e19fdc385eeabc08a",
"box": "417ffce4b30533ba40f8123e02cf75d432044af9a9d780d49977a6227334aec144ab23eedc1c5d35436a13d031d2cb622e7fc2ead4a242b6623254ee1614a74fb91abd9b989896fa0e5085d4e133c4"
},
{
"key": "e5d1e0e4813d531e2a45de8f1418f79648153e66814e5973b0a85aba93d393ad",
"nonce": "4df0fb80fb4bad3187863f63809da8a97c9c5d721ae82617",
"plaintext": "b556aa3a0962d73abdbcf8991c4917deb6c1586101c58415855bb21a7c3a257d1c3f8fdb117cf87c3776e3d2fafe9d12284d2d52f8f13493b2d266f4885f81fb",
"box": "5a6cf6ff569a608075d81dbffedb870472bbdde9b2751c9e440f8c5273b6894705e907e1705e588f5303b9fc519cc403844e6aaae4832bd48daf48da594c552dafe599e68c31849370ce052f4e74bae6"
},
{
"key": "e1ae6a921d1289b106a7fe0466f4a2fde25813dd8960663570dd6b27958cb113",
"nonce": "c9d6fa7c61ff97b8024a0c5e96925bdc2a1e2e81b30c4986",
"plaintext": "39ab69ba5bc85acada6f20b63faa2152888d29e08c38fe856232372b83787d517cbd323521ba61a4471a896825f5fa0e8b967942620c5b59129419f8bf732d6b8f",
"box": "cc5546e8ae0dac02c6da8e0e9d31c5da7fdff7f3158335bbb4ffb0bd3582d1c870a9e7364030711f62452e956ff55350c1a6c9356325d1e1f6cbd4d90b0a7b66260bd8c78059e6e8316c8df61ceb1404d3"
},
{
"key": "a2c9f0eea2a6eee8f2d07f810aa25e20d4b985b0e5db8908f8ad300c062d39c1",
"nonce": "5eb3ce3f48de9c6ee7604bf9ac2fd22b4a5a81ee78731f87",
"plaintext": "0dc176a9f93f8e9db2054c65fa94f1421730eab268a146b5a8dbdf85075c02751a33a2d84f85bc137ff47f6a2288db417141612d3842d56088509017e747925986641ef2b3c9c4757e46cc3b04993af94f36233b598a90a5df5e459acf74ea26f2bb223f295575a338a6ca0f08082437747a9d4e700103636d384c1116b81c",
"box": "57d259737140804d686ef7ecc937e01c39563a047ecaf313c343b1305054d9c019d3dded144d363b15d2db6323dc218813c85f16313eee68483a4cc2e36fc47a46e8da7d414cbb807e69c87d8607f97c8450f1d6ccac73a7e76c4176288e5db8266bad345fcc3d166a31fc1e445e78b6f9024a1e357b6c8e5af44ad2367330afe6087a6670359ccd1c9463463e325c"
},
{
"key": "3fab100a3a29940289a5703a2d8f8ea65393bd277560f6c97a9ea0e209b8c61c",
"nonce": "010757831a6af8453e82eb42002510644dfb0c8087828992",
"plaintext": "8e5d3f8451b4bdb4fb718337250abf44bd32ee9a810644ebb057cb23267a92ad8fca65ce117c6f3ada5200ac05d9f58d67ac2a7e4d015aa2a3f587a355013b47d25d52d47928a732e4e2381b8fc1ed56e1fab9b425b954444efd7ee4c24883b01ad38985d6d17c6c76cd9c0df1bd2b62d7ad98ad8435ba03e04401486ada287a",
"box": "5a4e26d9148f81a35e94e94588020162b32e055b17386aae27b2ac6b3822712cb7ef1426489dd239bb43749a78017f13df2843f5e1c04b0226219682a3eeafe55febf16b46c4286be31928955b5577cc21c194d53ef7527592b0bbf44605d3ad77cccaa741cd2e4593d6acd8de3a6924786e015bd50d09241a15334f902f2f439f447f7dd48faf4ab1fde2beeda54e3f"
},
{
"key": "235aa566c7c33b9644a167dd45fea948f75295c1f8283bd1c27cc5c026d26644",
"nonce": "2d2c6e885158eb662b41b5cc96b7aa98a6fe08124b10e010",
"plaintext": "f87b4dbc409905b3b161169ef832d9fa88e78ccb265b2fadab4c66ad574a9a85daef902a958515ac525aac2f5d3743a84fb8bbb6a05563d42c0aa8fbddc1b8ca3c7bc66196ab86905ef5092ad3cb1f603ff027bf8f1c59c7859563735bffb9030c6ad51754a2883b1440bb146de23439ce5244bba26a813de1fb2096a3a9de8e9bfb8aed552c91b1a5cc2f3aacbae64c36f6350fd41713b71b6a45665552db504245ca13a02de29464f25396a5abf193aeaf66e336dde81940e134c268841077c6ab6b7bbe63963848277e672986604580d5c0cef22744945a2265b74b059ea4b2bd14cc1723b9b2bc5b289f346779c4fb850bcec30db8c72747ee3efa44782c583c38cb7bb534dbada3c3d6ab4cd03e5aa32984c2ad9a3caf8847509a49428e2520c175926ac82eb19626271db4e76d81f53cdf4bd69581584a8f87987195765e0c9e074e235bd08c682e2f1dd57bdbb00025fb41322965664f1f6d6f5313f5ef44fe511271b18d5bfcb140e377159e2838a20c98cfef0cb02355439bc695ee759201c38fd81c40cb17a492284e74c6ef965855f01e09af7fc72b343ce09b01fdadd3327c17098b9dddb7e571d6cd98d38caa3efc8c0f8007c28d381a22db4f26f95c8d6d1f935cd920d7fc9cbfe5186aa5a3c09e42b2bfb7ee7e1253ccb363dc9912785a89f881ffef87ec4fc9f4d7dcd5471f451770141212f7b9e7db3d426fa1184eec25951c0517c42482cc893f9395ac58fc5d5a49409e07f5419b6ca01c05f20132cceda37763600431ab924fc1008e8e2307d47cecd44e3b162b66cfa41ce9b8d4e10743a18bed62df823e8df0651daf76b4405e0843a6647209861ecac15fb5069efcba841d809ce244f242f8be2b1436772f9d6a39e5660893a5f0ccff99f4719be6f786e1606c62e013cf08988f3f24cef4dbf4c7975c841b294b5b7c14599663055d61a2a8ac4deb82487ee12ddf1b40c588c6940e1d5438c317be8a0aba589aa9f4f1b16c95ba2a997e8e2ead4282be4bcf0f1c145439ea09716c9550da3c5ad4ac29aa9528a1e90200a5c845ac074587348f2d1d64083afdebdbc189b71da4ca95784996934e5b6aee3a2260162af46ceaddc67eff139e3d8e6d124e4e0fe55d61b7118d7e9c0bc9c3e4ffbd4f78e89a2d069a2b9816fb3dc91ab180c76c67684ed0685beefa429971db775ee5ea305b5ed39cd743d970c5225978d065e37b5addaaf1306801a4dc4f34c62fdf5155ba8ff7c6d195a41741dec2e16260c88b0d125d10b8d1a41d532c973263464269bb2a03247376692def990e54693038715bf0f45b74c4fd256c8ae5a66855ba52a3a7e5cb2968d61faca65eb7dea791b17b5fd52f1858345ad8fc832a73a05fab2d1b4f17c30497dea196372bea8283133b3b",
"box": "00a19b138706796db83e42d2c03661e98967ec739c751d9b034a28f7a9077c229f4defee01d647a894a349fdff7ccc57333ef123206b1d8063fd464372543debab12fd9440ed9656c8cc12daaa0c839a3e9e8abc27b9dd03c66518e255cb8e1a154bf46f8541d74732105d789937036e74198cd407c597920d0473710f39e7b9244d22a2466b807e10a090287a6bed5ee2846ab0f8d7f98f9f77934e97c77f5c24aa159ec55638cbe7070af18ef9d2a66e05b19bf6c65369b7d16a90c873acdc73ad6a10ab8fd87cc313669a1b3f8001b8cbf41bc13f65cb391a7c0cee51b00be84908738ff7519e30d9b1ce1e3e6dbef19e4b418dad0e02264fef92a19b6594a61ab81dc83edff73e2c9da4f9a60232a4d39809030082be62abcb4d7d1d2378378373a51cdf1ddd8e353e7e1188543328e10b766a2cd715b10ca25b019aaa7d7d80f8ae02bb9c0a811f2a97480620f5fa00c3c1623be0d1238d04de749184799f0b37da73619046ea9cb849cb0f000b764f3b29babe3f6febd5ff5b83a7a8bc3f622967b07cdba692810ecd352d4ea293472cbd09068c4dfae0d8bd3fa45548949fb68027671cc35d22e1086fbd2ab9d4c641b0cd4f1a98c69d3bcd3a7d06233b26d47eb4cfce0c3f2c9f44f9edd2a7a4809047f9bbbe8ed8630bb5f981cbbdd1ba477883be53d220acde436a363315fcd720b14fbcc186b2e172dd1dbf2967d4ab3316c54bc7b719eefc443421c9cfa654dadeed6f5b0c142f8dce9e91be2f74909b5c4b31dedb9a117c507af5bb724e7cbb7df646d7fb78c412d0ba8c717f8a8cf842844fb5c0d5ae53b6315202e55f97c7af00b1ef52e00d76ee93e0c550b66376b7226ee74fc1efaa070d1559ac2932d2f01f774a9156fa125f2a43c941c62b5d27e9ae531043a9a32830e603079e207bfbfe3ac4f1c5bae1ad3aa4187d54a5c5960c662161dd05d3170e5e55c0045b6be5ca0584710f751d7ff72a7e4619fa4971c4d3a182e139f3f57197b8b7aef42d0b4ac48b9971e4f0814f304cd7e083fa424aa315916b2f3bf719a20ccafae724ab0a584fd16c0787850be73ff589152e345526e082f880207cf5c4e0c0d61f80ed20e76ec7016cc6a3635eef47bac64561d00d4a84e68855444e4a557f87babbb732cf900800d3011c23194871e7b114354ab00bc68bccf611b1e28775b1b03a21c076614657f8fbf9e8c59fe5c067ad8b15e1f4f7e9a7629c3f03b07c6b5c5e8d85bfa9fad90df7670b8f76d25f0a7a25dad9be9ed65e137368e3baea7e4fbc062536fe7364d768297adc3affb570faccf9487bd9fdf479b2a5a427d052ac38b0d17c485632c4ca288502901c7db1aa6c083d1a2bac5f3c55061df9cbf411abbe9fbcd11d582ffd714b3e2179d515406c914c41c06d31689da110454e1bc7fab9fc232126"
}
],
"frames": [
{
"key": "9f4512876252ee9edf952833c494c6f9257c29493e6f3abea129b4e807ea614d18116ff428e3767dd95bf163494d742c0531371f52beb71641bef544719679492098506c5dd01d67",
"payloads": [
"",
"00",
"7429f00fdfd2ef906651a818189d011f6bbd0f7f9a2ebef4bc31227a04a3dfbe560113875020f09eb1aca4eb51e58e80960ae2a114aed9041fe098a788661732e6f7c08b2620a60566be4e11b2e97f6441e727dafdd0eaecf12a319abc4d93fc7813880a",
"11e97cd5b5f070e275af4b5ce43a66d21854dd8860b0829c2d566ee839ca54964e29000b5149fbac471af488a1a9926bb005783926a17fa84ea95bb6590d1be4e38bd40760ca9f8cc5dc56668f65071f7b66bfe9699589f15a17b13f1ad6b8955d033342fd24eec105aa1d3d3d8f14a958898c80201494f7caf7559ae83cf7babe9b797c7c9baa12b47d22f0677a5c5be9950b81c0ee264211ce0fb8a2867791788dddc1cd9bd34ca5056038897a7d7cc72d13d9ce95ea6a37c9b19e1cf30d697ed1337246f146caa9b3c44a766875c2dd9c8bcadc21db4bec73ea70d47e2b2f2a210c1704ea9148ad651486f5dd125f5f8ef5157ac34baf09f9f834d51fc46939dee2dfe66d6007c82906fa9d087c9f9bdf9263b75571a5a91f081f60771ed45421c5029bb7995b9ada1ae5c40b902de45b6327e8faf913600605e80e4cf551f8971e69c5a606b6597c9ab3aff6202ccff9bc09c7e97e685f6fde007398363e1a6af88f9aa63b1b8ec8b1f5cbd68e3b1f79a7772ff1c07ff421f40135ac004950375954d665bed2065e06f727203b48f34bae2ab71b35d028e21fd49fab43515c245048063ff090a3f69825ddbf780bf60dfadc4de2c42e466e22658fc922762f990a541dabadab58927c93cdca34cf5d2b333f1297b2d310385b9efcc499bfc5885f2a95a8f4765b602b330aa83fe6708140820106d9b911463e5a9d6b139643ecf057b60d2a1190ab78cb88799b25f8612f992a1d41799c93ac4950d570ceaa8b9f037038881e35fb87887e901f8476575cf9d30b9617d9e95ae5dd46e2ef792fb7170c738c1218902e9d571ae9c2367540f539a79f278bd2ec13c3589e5109bca491d9b314ddbf68d077f26c760f500f99a105304d96cbb3edcbe2590ac2a482a633f1a8ce81f0a40e167d376d32a03460890d5ddc07ec3f2f1c12a3f604dc25b2123a33080b554ef62ab2e8bf47d6ed810da9d9f6fa58ad136095db010f8bad33f0eb490f034278d3fbadc32322d859aad33d944202464c75f99682429cf607104e924596ab06fd63869e3206280c2a2fe05faa3f55855d3957105dd50e9ddab05d8905764900bcad3e45f9e1cb47c060206fee87e657f194c5f6a078f911ca7fe5b19970a12a0d9518166d57f12013e9b36c9eaff15556b86669743be72bdfbf92fdfffa5271012ca24a22f2d6e38c7721b5bff099a41785b257245560ee7667c6ae8c2a0075a40c6af0153bed6a600b804e89b612f1caa23d663147cb95ccfbd7656ea33bbfba3227b72dc0d6e397eb2c244542e7612653be556696fb84dc24a313ca925beb60f0c7a1140ac680ff59be49ca3d4ba0da2d0e0414e6dcadf883b12ec201541bed8ee0ed0fb2b17c24e08204e95580502dc2a395c25c91c3da66a62dbc2e69d60c8e55e27fbdc11b3b505136a1cb390e2f619698015393cca9d7207ab4ff0a597d29e6e2160fc07d634f4c968b1503d430c463f9bada468425f6eebe44609329d122f40d3c5f71e0d5023ca9add36b49e6d720d80d657be0b2af212d8bfd3375637e94846f9f109edf213b4473effc3774b71862266d42b77cc799afe45fa2bbbde3abd6a7a6d0e324332de9d0a0c7970e8ff715ca83dc01d57c2c14200577efbff67f912fa3ef1e46a8676e925727056a1b998aeafb862325a68ff7010e179c7263f13d6cf636abbf1db2a4731000e38a1b282cc0cc3a318b0ae9387d4f1938b4c009147ed91d58e1433647f9a44d6ed5e640518898cfe05c3b705ebe7342182957b5c003ba91acad906898303d8ba8d120b583d75f57c3b8535e8a476f6e10a265ac0f21a91f812ac53a6907b84391805ea421225947550aa12b1a2b296168318e3eab132ba7336c9af8b1e16d4484baba0b5cd9fcea13adae9084f98666d407075835a3fc8ba02f2aea449e0b8a2a9935f216eceb3762b3f408b20c4ef5140e73f5cd996d2ec9595bb55d438697ebd2943c866ccdb36bf22d84510a8d3e9b6b68e95b65aa4ccad23fe0b26b",
"02b6024e2db76d216c969087895114a367"
],
"frames": [
"9ea21a090c8bf3a1bf53b91fc12d22988b22",
"33b72a8aafa39f797bb8c9e9cc0cc456189336",
"233eb3ebc0ecadb553092fd508701e68034151460eea44191664552deee10e8341bfd9519d4a4e4ffeb50867f74e02ad876b7a361e4454697e5d505e96349613f922fd3e3ab2c4190fcd7b89cdcbcfc53c71fb79d141f9dc39fc5287c1a329d97afb421c36052abd5dc2551d695446b9f81c14d77759",
"efb943e7d84cb0b06cfb5c851d0feb2973cff4b0519d415036519cf4aca1b5dc739ef1a0ec46be2f4f0ba2c33d3031f16fb8817aac51e4bfae084176e4d8a00d1000d85598660fe832c02cef8eef6043da94906456c46e51941c2a2e75cfb6d1eaba7a8e6c3923c43de53badd0b706bb5bbf67fbe19280a97e15d9397a8a6bfd1e29d13a99fbdf059c229595599d9c38501c4e36ba66624fc6a36c96344dee2c61e79c5547c643dde9b62490c92756ffe64bfba7a16e30f067101bfdbf8288cb745d6779276292c36710c997a494a1fd7121d6b8fbf9ee1f544ad103d4151e022ec1eb0522b5e193ce0c6aa038c107dc1b5461a3722877d4a20d5e0a0d4b4390c94a9a0a75520320cd6a4babe6644209110bc297daaf9e8980991696281c07e4648b70eb8f1a3cf0740b547af19a3bcf5ed48201939ad0f5c125ae34dc8a90f645c46d02eefb5ad27b9ab57aeaabc7e38324f7684a22ad044afa9c8429dd28456003559e672d24201b186b6ccd3e6faf28a3e8f57048b11f422df60f4b5f9368cdad70e44a6cd79f90949aa13da4701ad05aa53d27d928fdf9d03c98271c1215d5b8b766320d8df94d93005dd062ea4a145205a6df6fbe709a27e6e66c7e16699f500a02a0cbe9e97c3a83498e005fb8dff288322a4e160a831e09cced7fbde4d4956dcd1f82d40a501ad28ca3ee20dbd43c7129b51b2f7405b80f3e3b0f12fcf1ab47f5ac33b6caf822129f0c2e5e31a8b9fb49ae7426237fc1352ffd8785b3ceb601b77f662eba4394865fe10f371d149b8251e7ff91da2ed4f04913053fdd1849bc8560f4c8dad200b914614293ebabd4f8a9b3b014bada8f63324e800c147b358ab900d8278f93e50e1c02c849f9bedb02476eff02dbacacc3cec9993cb3a498b7b4192799f973a1b3f9a83bfa995a1d85fba3badafbe9ad7d7c88d795e37af1f954288948645d59c6bc95d118548f4a24caf5a3044c71cbcd97f615671e9a0c6a9164e3152b34dc9e79dee70b79294104bf4006bab42175b24132578ca9ef501cc7b9d154308905efab033d02fcf560744e9635d0da579e68bd892dfee167f0a2ab5f52c84cbe51a3d759d7b5c7c82f0c4b22e8a96936e4c3f157eb314a7942cbb3743b079b5c036594e69b922f79a3da21ed571f5e29b191a1bd75921046fa8ac9c2511851d0cfcd5e00581e79f59108454e526acc0e8392d845964087507f8a40cf5439e9e68800a4e8793edf48bebd4244b6d99fc61e08aca7b35fa4e1ac440fa0ad3030e9634d63f88b58f349b36fd2c3ed4f7e2cc8f3f4a34ff545737d400a7da3d6faa23068bed50c380d32f8295e888b20c42e032581729bc5d89bdada5f8ffc6e5e25e5cc7f3ff2403c58fab871a3e2cd3282b2948a5082e03a737ce3a9f58756155cd7048cf9d33ac09087b9773c90ded5f8bbdc81b2b560d0efab802598081932369baa48ac03367f3c260eafef55e429e5fdf69bb3036e966a1c20cfff3ced12cd095c828854c957fd31ab4f01f36c966734024c5b8df3d1c984360c4be318c3c36ad82f13f041eaab19a6973488affc83f0d0972079db633704921c3e3fc767d0d1d4ca0cd2c419ca8afef50e34f62859c83a709be768cf8efab7a5410c92c707bdd0797c95197d5a5eb20093e8f01f27ff87bc902e44b7d583fa3b9e3e54a388131ec5fb56e8dddcee628174cd849befd74e73db49822de05ce1e2ff0819a2709638a95ffd50b1209de71626299e134bc2464dbbeebef0b638dd3f2d49ae426e6703660e71f4a70cffdc2915867aa6c5a20f675eb3ca1596d54f3020590aee2d08afcf72560e0cf2357110925e1f7a4b43df94e9be1d0ad6a5cb246775af6d6c4388c223451485b91de3ab0974b8703e9b59782736dbaea105776f24bb47115801096be48b98f4939a7d4d68bc804a3523713b0b7fe2362cd5071bf8a753f05f3c8714a519e0271721c218d449fa488935e46d9294926f90b8e6acffe27bbd1f61fe46c6a4c0c05e4dc143e019203649ceb8a4180ca149b7d48ee0c66d752f",
"24756acd27971bd2daf0e8476c923db1b7918886d7f1181f7496ce052523b21c00831d"
]
},
{
"key": "67c293b980defca8f8480290a08f2905ade17c08a5f8f7ba4a5d05bc3a2ea0b798316d3c7f25703ad6d17a7391cd26964e74086194417c53d93ca8ec07e92922da38fd3d91f8ba93",
"payloads": [
"",
"d7",
"ece4fe6bdbe5e9f1b9d4af51ead088482cc9afe52e2640d24c83487466f5a973a098406bf230cdeeea9899b56904f4a1f43d8749c1a307986a02d9c10225c04c855fd3ddbcc7519f89d5221018a196a928bab51338bd48a52b10832251461405de2795aa",
"c5074cc91d03e368c1b01b920d3bf39a13dedd217db76b96ee4b271afadf2c6300c8a33f1e753ebbc2ff3ffc4b553a0c91b0223278cd276e89da34a93733f5a2d6145eeb271d00589b446103f1070a0adf05980db0f028813e884676a5e5eb7ad5edc7aaa7e898f0535412380ee85dc7363ba030460bbaf92ebfe63adc10e376e6449de3687bcaed40e0521fd03881b0a349ae4bf15b607756d27935667e5527ae786671530f3d9162a3baee39870aa250065a856c6a7fe89551de41560503cfe073a8096bfe6b0d8bff7497e362316f902eb4adc6f7475b44a9cdcab7c5e999afca741f8fdad5209551f5423ff2c27dd07cb7e804be43a52044c74738f1672ad613024c30eedeb4dd55306582c35c99890df0f8b234b82d0ee0c3b8e6302b9058f8a70cb558086008a58e527ff62993f0798e9818f7bca528468183c3d423518fda4779e4b2ea6aef0ba47636f2e17f27123d7d2ae053831438fbcd1b4a46e486cf21f7c715197d66badf5ab61f1c03a850fbb1d852c3d81a93a9a1f09419d5c31525f0725e3e9838ffaea2434f5875cbb06de22da7ea1c2edc288db9a5f2fec6afb3256cf8c360ceac9686456bc65853f7ff413b20f1976dc56e1a32f5c03387ce9c74786416e413a9e8d7ec8d6d2b98bcc1e861833438314f091a0b5c9f7c8c13a2a343cea1a69b413d2a73ce6fa0c4f2976b70b9e67223c84bf929e046765d09703857976fbbd291358297311042abafa1193367966841a92a826294c3767e569a1e9e214cdfabb23a923b996a1caf97d4b40bd273f23ba55628529092672a51ae79948ad9e9d7117c4301baff3900539f99f2521e32637763bd1d061aee1bd44ebaec71827b764bf1beeb643ceb0a3a4e7331e9d1d8b58bbdb330eb8e07338d633f7151dc85e925ffaae5ccc92540dfc9b7a5ea6a0c980f33d0f24d793e2afbcef5efe7fd800d6b6621673235b63a1e5c16aee5a62a651762855eadafa8072d3200b9ccdb53575cbb859839b827ef4f243567c8bc8aecee02f275d578561410c6b5d146aaae11eb010cf23d32a1cd9e92318405642992d4ce6c16a646e2f240b2d89c9febd1ee2e0c54202265672b17c788c5eef58e0ad618d1bb4643a43ceb8d2fc5830a5dce402c50d547eeac30586b4f7b8f7702c57a5d7d751d3e503b83c1542041dcd86f99145b2e6a3a5eadb02c4d6f31dbb64e93d07040523c99c65624ec7c1d631c09eb17e382753d9d7c880e254b577096734669be7f4e59815d650e9a59af44e12e190d46b998f1c0c318c9d72bdf35b40a7e2e4f0136122aafad468e74a35a6911f0b6e77f4aea3921426d0251a5e09f69f2feffc423d7d635b581257e85ef8afa5aa5a22cb8e95dd55b1677f3e1f9cfd4062576ddf326af7984fcf57587b657d7ad556415a98e108431f44d766a61f49fecae1c55f335d30361ca17bc266a661e6aa5313075c8652dc31c641dc16a9a1a3e0d8d68b33f780b498b82b372e8ec08e144ac97959a71cf0da26762dc1f06070d2cda217a1c27ef14806723d24514b922435bc0818199e39e921bb03989ddb198231196747943330ee4dd142e9ecd5f00e0cbe3d4f7ef3e8752d48ef15e101321f1cac645d84679e1931a888b8ce37d6485313282e30bd741ff6ba8489c3cd9438b0bb9d583ff06095a7acd1825dec3af61e8dd69b0a51e6f0f33a2585f1900eeb12d5dc65181daffd49f2b1dbe43eaaeb5874d879a815bbc055d21a8093ea5031e4fde146715bb80abd1fe899904117050d83a0e3213c15cde2d983a1e4a5f8123b08ed3a0d74c00ca83588e1edc76b6720641a4fdd0329f765b518d252b85df2c9121c5c91e04063e97029103c30b8fa6c50e89f0549d07124b647c39b7a81bd3536ce0000268459e67f4ab1e0b66376e23eca1010d1868a6c68260022f0ec25feaa7c54dd0f502b5810d414fdae6c13afb68cd271c3d191b48e37111ad4eba2fea7a4bc33a1b99eaac2bfc45a5acf241126d1b170ee5c3e2e2714c",
"0efa3ae08438b052b5d17d26d601bb2c03"
],
"frames": [
"81e79b6d5b45f41c224b2310a67f302e3ac5",
"edf70cb72e7beec6d3366d4f5e00c9878d1621",
"495b29a8f9693152984e63ed03d7e76dfe66a11d4e888036156d8daaa4b52175999e1220ebf5232b0612666e59fb91b492a80b280bd41a979bd9ee3c0957b7ae14205774ca2aade13accd8defd18a58b8bce39d1f58aa576d59387140b10638326426b415d9f857808a4d7e78f99f95ec4fbcf3c107a",
"938d98ac25ba6518782ddc71c43943c540c8b44d80d9a0155f2449b5a8da17e0ef9d83782984c2fe39748fffb683ad36ada68ec598db0696e1871faaa659711eeea2740bf7fedd130af94f47a0ac81e19680dde1f19547bbfd0d149a2b3b4338eb83a4a0528f9962832a68af709b38ff3fa9c61274bd5a96c378281985d385cf02d0f5c85e5ecd44bd5c5afcc3249e6e154ee22a65d6abd1eaea833694be1c1ce51516dd07d3860249b709ab768d113aea7f03c67929432526d787155cba7f8840fc8861c0921f15fe12e8df189dd68a5669998e16c49cb3ca4ce74738a42d561f564e0f3232299d5009b1d41255a3ce45ac54525a3d9002f192a87742d86c9e865c53a04edd77a022c707066cb923b3dad230ad7e7fb5ccae1c52d1b8750a21648141cf1786aae4941cc016ba387a16e900f32a8bb2b5982f7d3e3f40162b4dd1068a90307a279db79683bc1f0505621de8ac6f2b0ef38cda6d22081a9053e84c93f77630bf2923a1bc7d944f1698c85d182965d5f20a8cebb9d3384792a2f74789efb125e252ab8ebe20e1fb3a4e3b8e41a83967408ceb5d4e1fd449c5fd777cbc92f4a0b1871b3120c2221bd1775388b33a7041c13d366b872aeeae16dc6c016f03f8d1d526bfa227616fe8f70038e718d07e0545d28d0fd7b759d94bfabf9c6eb5b288bfcebc6323b440296bb3878b41a75875859e15294f74da9a21175888f23cca16cee3735403eedcb08292ee799eefd827da2ba74113b17b76c79b3cdc6b13684197510b6fa9805e9f697a5794ec372b63bde4aa7382d35ab8596cc87ae164e42d66670d32e3f171a32653e4f58e1521364f588147ab477cf5d3c4afeadce3be116b80c92552cd628a77ca9aaeec9fed9e3ee5ac0c519705bcecf7457c59c707af3f855d9ca073cc44d8f6ce2521e756fd7b11a00ea7365959d9a01c7a7ec6cd8b8a76189cb1336fad4a98576a0f0509c8dc54eff9f6b70b6aa87b20e1a0e536fa2fae50577fd4d26afaf46d4e415d0a3214dca4ace14a765cfe89c6fde691a4a6f3276dcf65d5c84f29f87b8561b6fff14e9ef11a279be32aec558956b391a5c9bfc16818194766139333c3b95e75fbb40fc0cb28ac7dfca14d22533643cd10e9520aaba5c01c9f6a0d877d2f7d467c55ca2cac100b0146bda6719aad26423d7831b6199da33eb583398763ec0e8218407b5f27888bb021b2b9425613c44d23bb02e6d45f2ca28f89b7eef02cface5793ed7485a371a03127ed073979bb1a811d854cc17a0f14c326dd694adba84eb04cf0d6b640606b6f0a48e3d83931aa83fa418e0fe4d5ac37d7e185cf489090713ddf34843991056e56e20d9517f3b65cb302fbbb0741825e352ae834f48df174ab4380bf2bd168252624c49355da5081f82b9884078c537e94c4fe0c640588bbadf436b2d9ad267ca1ef1470cd9a88cd8c52f559fa1dfaaa507d3d669259d8e6c398c3a4403b1750ea9a800e6920e5a1ecdb1cf144ecdc86d247a5b178e131f846eb268c19932c78699b9bc2fc507b079c7db5047ae335cce14e6d2c37c8f68da10591e288595379afcec58e28318f2a08e009d598298f07dd5954721cd604eaacdcd7d558d4286b7b623b0e55d2648b52379e3fbe1d632772ad7df90f6b232d7dd702c4872f4d90259cbdcd0eaef68d009ace02afff0c5440c903a84e8516bca456b115da76d61dc251327cb30d3eb85c0020b95b020b21f7c328bc56f9e81c50f79dec661f8a9df3410af762082d7599f7395c8ea18b45e00fadd9c9b9bcea972c2c6dbe9f43957bcacd54fbd1f9cd55785605112af69a4c89bbec1c3755791ec16115017d16f3b3c310167f9c06b5e0c27a79cc0c388b5f6583b63d73143798246f5fedd84e9a5b913ce389856632fd168d657a1d9ce8064068f66905005e30ca87dbe1ec2d5e47010300ad7ed5a584f5feeb4acd6f276b49f8734ac9bd179802439332379815374e273db8b94fe8048af46d6dab0eb2498f59a75be97796ab17dc01b034a8ec616178021884abc3a2a3d77a",
"d660baf6383dcc9e584840780d9e6befdfa6cf4b27dd7897551c3749f0ee7563acb3e4"
]
},
{
"key": "672464724e9b30c65cf2e744e08976a4cd407bc148dbc320fe4439927560a497abd3548ebab6844634ef141778c556fe4421e3ffd5d58836b823bf647b3af4278f011538c58fbafc",
"payloads": [
"",
"77",
"4faef2eb5cbc641cdddabff7571a8d6711332e3f0e187a209d65af7864f4016456b94a2e39f176339cfdd37357165545ca71790de16261520fc7b53f6bff94f7e6f8d0198d24a289c9847590c8749785c51a53db8e939c626bc4a1a0afd83adf9efd57dd",
"f878640dfbc740707a823c71b83bd2e0af3efe0449b7d92a423254b1afa776a3ee548d34907429704a6662783666d6d38ae6ef5fe5693cf935df88eae19b49c6666781a93b8569d58caa8e758d2f1905e4b12ecfbd9cd8197337c6431e8c144d2e15245ef07df32c420577c3473a130f651ea54e83c54a68ad3984d930f51c60fd4970e02ab0ea6e44b94e0b981c76a37a331d6ba4257c756f2147c0bcbea2998300aa7b0fcaef69929ca32fa3407308363b53a5d28cdcd9cf29fdf54c8f74dae3e89d4a4197dd5b1a3fe624cd3a89920c41c2c3ce1bda78bd9a98733229ad304741f2c4a5dcd6c6df1f613f670712e7c62ec61b6ac189f3bea2828e5f46fccd0aefd92858242335e003ae5e05064deca62f0da7b90db7dac30612f9a4ec2c6e242142e19dc4d5943e8397e59766a406bf672d1ffca4f60b79498a63688dc4cde646ff4e7d313dd37207c3bf2e54b00b751512ca67166a7f9634732d1459712b751f5fdd982b23ac1c06e7d6ea2628fd7590c547d59526868bd3651dfa63319e8d48ffba1c9636468c3ac16ce2605e4d8cc5aa94b88a71494e1d79ab46832d9ab0caa5c0532430d049b39238bf364de27d87c989c5f0fb8380099c6be302499bb2558e795bc9def64b2618e5fd7688175d5289c02749ba3807256f8ad08c039eeb73a7c774c08944b6853dc15f6d7c0660dd5cf1f074e42936defe8907183fc8a9cd909eb5b481c99936bb28b26de1b8fec1a6b837cb14d362de9bf35d6a4bbe819af30383db094e41a1a1625fbcde42f9e2ca3fdf7b920ced9dcd5d34d524d4e9c87109c0fa1d167a0ca9f62fb2e7cfb863a669412bb65505b3fb3dfcf0a9b677498bc1e94d066c6b4f13d3dcc0a3a91cb293e576b4b61e54745affe6c69c76e540206950cca388000de1150fd0b7dc56182ba79881032ca8e2cfd080cdba02b3286df8fd33c4883dcc6391d6d72e88f222a0510cc2d3c7e4bea0b84b6b25aa8f83648b0c91b1cbc67bd749bb222cf8533f54811faf6a585916e31d33829b1446b1573ae24c3b8d96189981a2415ef15eb6e465a6897b86bc77755584c08550e0dcd5f323b4f5cd127bfa81996245c40c1d1804498d4397a000d0b5d882dfbad3d170257b74f0ab1d1be6da07107317b1bfa62c97c44fce0bd0219990bde0c48d4b592bbcc84bb790d10037939db988f09ab90681af9c44df0554ae57a42bb5f6182fe2a6e47abd832c4c38fca71312b5840d8c977b09f0af2ca7c70af56c086d0979797d28fa72c50b0a89b793dbf19d2b4e814c21036983a1b99e8365ae2706dfee82b0aad2920689acb27134d4e79c40fe19dc5a1df2c434ec91dd638c295e9ff867f4994cefdfb36ca41e08c9a3438082d8a99790f85364ee65d6377aab86e8ac99bc2938adc09619ade6ba80a9c78ee493035ce8e7f48ee89aba03ecf9d71b97109fc6a58c1cf8582395e8c4905a74eabddc5696a54dd962f3eceaea691a450747f52b7db2a19be76e29ed9c56a52e253850a09db6b6ae3983c85f21e9a72c80cc2871bf7664ca9e8bf5a24f6934bae982af528f440b3f578066dbc639aea91fa8ebff709a0d5e2a04f373cbe46a5e35ce9aeafe60aea42b911d0de56b587baf4ceda972fbf64ab890d09c63b5a3b9d34ea47cfc674fc0283b8e502852150b81b8725307d944cf95234a9a0b5efbca60d9c75c69b394dec91fbd240d14ce06e21c790a996f0e31051eb1d6fbc181c9b777382115ef2d453eed288d04014223f165ee8771f5496155422ec911b3a6454b197019af241d8da54949acb1d229d75130554a4593f34bf09692dd3ae1be6e98d55c6b89a2b207c0dcbde0f4270666f3a0024c213ff55a03df3dfab801fa038d2353c6547f0537c1dcd1ef65fd58f4e4a3ab34f9d587feb75e71983f081fc1573ba8b45140a3b625b01559f6af2d36687e12c23d3ec2d8d61c279a0ef90cc2f3d158c98a7bfe6edf7214c6328fd0ab0b258f3276f14ce929fd05ac68c9b5364b0d615d",
"e3be742cfb4bfa7e28ea60e9766490da7f"
],
"frames": [
"f002d7408f4700d63c9fb9a6124e94a1832b",
"a10ceae3519dfba87c300842ea272ba14c91d8",
"ca5db88113efda1dc41b30f7a48ea08ca48894d74c446200525ac13faf0270edb7b79e2ad2bb69ec00c56df68817b1f3025ca895b1d30e8ff54c6ee136add99764bb585e700717a8fdf46e90a7f79d045fc6da6eda4eb5966e57657a9662b31a858ddcbca0875ffd6a8f20bfbbf06dea4d7eace275b6",
"d0c408a1ca878a0f9981162fab752620ad176373a3785cc7410cba257f216e522e40fe3c8ac99e270d4706b1c3c7b75d8bce2ed095f78010c4f8abbb91779f0b264b00301773a1591f5a576049e2026ed343688e55f2202e48a123c8342c9a65f104e3d6f5f8ba0c51f65bfa5608373ab3b222ca33e5ae0cc3659fc6fb95ca289a423e0dd677cc03beb05dcf9adc856b39cd4ffdebaee81ab3d4137a79c2f8c75e07930b682b901684db4c95b2f6d80e4caef1b738d1b418209e6e45700501d6b36533f67c8dec48655062dc378a92e8c507a6332b406b6f451f304bb7ee63446f1437243ac58fee51d051667031be4744e3fd94da6dab7ca703a508726549157409d41b7cf58c2a20270664d4b9c4ae07fbcac69262fabd1f3a1a00ed15f8cd3aad71f49b94477015c2cf6de5f871f90f0db1ed5042810932923946fe5198b02023d28f85f94028d9c8e692a2721419bb72b5546a64aef5811832d6127907a4bba8db0384045b9d04b3f49677d95b66708ca3f550eaafe03789cc91d27e82d14de42046df37a8d1bd4a42e96bcab295ed23ef155cf8ca3149591b5b0f97e53ea7c8dfdaf2f82c77dd672619b597f91a73fe17587c0d6090e110dda8ec2c5431216e4629ac3067a8bfc996a6525beb85dea84dd1e0581689221b9000a324b5e26523501a39b1a141c637862fff7f2ee8e70a7b2542190591c890be1651bf204ac37f930d2b83afd3cf12507b64374ef52f261faba487da64231b9d66bcbbab8332a1db35eb7842ddfdd4c8081e34642203ae5664694ee6894278ee687b3098121e6372567ae46f644486bef37e97519ddaf357271a0a81044e0951b89d935a61494bba14b6af731dd71d9d5bdab61a1aa83495c572586c285017af04d3640fdb5be25c6fbc0b47c5dcba966578398a7518599721ea2115ea24410bb2eee6c888a0495a5129eb19f36c7ac9fd07e83673abea9d331bed28bb6ea9447e5570170479900c280948b932cd988623cfae2f8624922e776452ae36f01371dce24ccabb6d910df39fa52f8f9b36eba35e6f5900d0adf4b75e15c65cfa594849d6f4a886c1543cc699751d1582e16ff10f78baab300810d5591a84517d0b4578702a8c280812611daa1b5d7835c67aff307a8dc422084600ac9adc81839d89d6d5f10982057d47057c4662c5f2a02b7e1e816d6c80fe728820a8559330355c95a1046d0a1f1eed4a28c00b04af1b02ac09c55961c971ea671c648491756518c09ccd8d22ab65cd86cbe0266ab43bd2591fb7c76904f238fd8762ad7628979b0eb8712b32e889b43c216a2247919e99ddff2e721cb662ef098f11ce08b3bf7c6a7f10aecc2bc56a3d26b197beff2b15e0cb73179e9f659d0498404fa81d2006a8b6b7ddfcd0b0ff4ac660d6a741606dc1653440959b7eec70446f5f5933e12dfa20af4da1a15d354f03a971cfca6e6be2575d134d1beb05960988ac7956f4c6f770f698827a6d8d4ea51176c2cdcea7f59a3997e1d148fe5e610b2e70c3e510fdf881c6dde0c950aa9861b79d6cd4f212b57de1b464da94969b4ac2728da88bb4833c73ca70007a8dbe952d689b4912ae02a9f0f2ed7a90b39051da5f452817d19e59e6bc26cbef4b042b239204777ace92d4b4fdef63ed5dc582bc86a202fbdc8bde4efa4d7688e1a07c4b58dcd33778e71d80b22799e68955c83e3b0dcdb2c0bc0c3d57bc0f529111d2c174c22909b1029d3984245999bb9951665d650df4407f17c85c2bfd05772b4286af42ecefc0839e593ff96a3ec11e91c7ec101bc5c85b16f9d899cc37e90c3775eb29b918b7b0a2796940a83acc21a1ec7f01f8441fdf82a231e8d63e9915dd69dc898d4f3fdf80898b2dd2c38720f2e13bab98f7279d90a2a0ed7ee7b4b5bcdebd536d1bff5de9aafc7b95fec3d9cb6c0b7c2ad27784c047989f74906c891103507b6ca5fb441c61e6bf6f9191780180eb4c64058d47ec2a0399e7179c7254af9b2a2e669d30824b7424b521d5c9e21ed6aa01d8da824e4e762880995264710864",
"507b32a95c23192719850a5aed3c7eba3f558960966546ef5057dd827c1aad6033f781"
]
},
{
"key": "7136d0ffee125ec4032929c2bf9aca54e3cd892fddca30af5ea234a240b0fc582206f7e7d42fa1b762c378dfc5903888f773cdf415a65de812b303eeeae6e00fac9f93da07fd9632",
"payloads": [
"",
"73",
"fd1453a0aa222f09bc0aa3f9f4c3ee01bafa48482b66faa80e1d6db251562360bcc7c8eef1be7e21f8c5b6f9269f005ab363d8c319514001f4ef293ce41a187b97cc55cbe76a9d6b3d6ee91502863c077fc14d20f21d16c75938994f7ff29c64f283b16d",
"507ef39b925d64c4412df91252581112d250b3977ac4f2f4ea62f49e9eda6f2be2b92a99a5d30fb3a43ce6ccc92e469ff8706629ea0c853fd6b22c1eea2974b2818f58b29b5d9cdecf9a7e14f21c6aebd3ec73fd527a2a16ffe543206289a355590cef2dc8d84676864157342a9d3d36f9ac8b94afbf12f24b9a62f8f3b73610eafd2d0f36cccd7ca750cc6669e3a9bfb9f5e4f78e58a81102bb5044db017909773bd92e6c7f146b2c169ccade64b89cc8b76f6974fc149c6c2cd9b66875384635b894549a42150d85905816ca33f819fbc17445373f465370aae8979166674dcfb998d9ecc96564eaa5d7605f4893d3f35663841300582e0a1bc77d21a6633f9699eac9eb4c44532758c7815b299eb9d421e11ee0f981f96e690ccc0375db155714b9b39954d02c65d587ef95d49f646ea217d4a05c0dbd0cb1f8bfe6373a214d559ed7e7f3f91975aa9a87b2cbdc9000adbb10dd9edf1e47a3bde626b2d94593fa179b861c882bffaefa4edc16128ccceab6ae21af1e48f83959f6b138533851b1f4d7d57b81051366fd6ef04274d3dd1b59d0be11b421da81aa59bd6dd3658639b59233bff4279940d2c79da19fdbe136200a5164a1975d3904397f23bb8feed3db1690164f79299307f317bcefd5da2198d73d8b4bb0e39d687a1e72ca9fb5dcef73c5449c4a7b765d756f448724167a9037ceaa0360a904eb393d7ade371e77ded7cdaa574288bcf5bc393f36a9afe84ba86863e9cfd79dff9d32a213bdbf81bfb3fee180da0fccaa72e996a9acf240f01dc46cb49b0f36feb6fbb048ba0c4438637ab26fce411010a815eed4e7d48abe021447b89eb504144e946bc95318a12ce498a764a7212f7aad53d4b7c5df392fd7e27d9f84407c50231304b2b319337f825a9e645a40402b4180a04580c168966febab95e4579780ce29e8e276794ca554aed1127fe6c426168bc430da57713424496f870b87efb2041717b820684923a3cbe2c4e545572dce036a222c119ba769a9fae9075ed92ad7361c992b792c012a3021639b324411929c96755c529664a56c58eb0d3ebb3efc0129a7a58c8599361073890fb4cc7f9da53f87bd487d12d5f981bfe5d314436bc8236c5c42f2d7fd58562ad69f71f88ab21f60b3d684e800d4cb01438b06179ca0c4bddb0113edb186fc2cf3900775452e2a5c06cd9eaae549f914341da1891bb2f6cc17b9c37a085282e095b2cd0ffe07574474942d9d1625deb723890fa6b9f856de0f115c2a664824846ba7c826b02f7f3c37509a4c31776e63ffe49aa8feb0c7eeb30e39d619b4121a89f1cafa4a5f59f1768851ff4fe9e2c1799208b048ca13ab919c95e8b06ddac8854528cc245ec76ab6f002547ba276279ab072978bbc5f73f8abac60878ad0c7416c077f3a16602719847096ccfd8d7312032e0cb8467941124789216634c693da40145f3271b95fc5cd230b59871a3792f6eb49a1b484b2c71f0a00b34e13662f32e80e6ad9a7914d9c30db79c100c99d2c78b3a258321f4caf4da0288f749f5a3f94abbeb9a69a576f0e595ae9a6f2446029d94208bdaa127f69259a4e2c153ea52a74dd06dcd04dfa65e5e46cc1ae5cc72fc62d4d90ec7717421403de7f7dfce40c69488890308057ce99d91055f44c2ed533aa362a30fb350efaa115a52dacb1dd8a06f329015001c7f2c0b22229da8274c9cfc4615f19257001e52bdc461bb81a32839ee6fe5bde8723b6bf518d60725d23891ba46054666cdfcd9c329eea5061861d2c60921d996ccd2b45478e41a76ff1a1f914d9d4e73d79d4d59354b53ffd549aaabd293029c0e6f3837603f932f943b2c6c137d0cba76dede93ac25683384d27845a1265d7baa28f4cbbeb7bbb49a78ac43c711e272c1ac5ccb9d05cba532de3b00f9fa65057e9ea596b87884c7aa56b705ab821589ade6c0bd90bd3457f098c16546db4894c9d7421c5ebaed029fa934021d8d3edaa133db7e50f095b8a4643077fddb54d178ea874b1",
"ff39abdf9d898ae51e174ba4ed7a21067a"
],
"frames": [
"2eecaf5f1fba6369c1f877e007e71f9f80b7",
"b83ba003a490f110cac478df95a938f38d11dc",
"cb1ec7d1ea7c0ea2486c48b734f437eab1b462a2767abcbdc89f50a2c2c6745db958bf8dcb11944d41e07dcd92b6d277ccc8be66c7797946fe2f9ffbf11526a2273e2b9b1aed454a15a2f3b95b2af848fa53fe8fb11d1b59c009302567b3dfcb6eb5d9d331d8b3ce33e38cc8e6f78d7b5a0475beb465",
"7d79fa0a4744a200e89e525c2cc82b92045aebb9afa3d2bd115bbfbea1a4bbc4477e093fab56d75364d1890128dd30d7fed589253d2ac8c3fa0395b1b6cc5f89eb9d4d0b5d684e14d084e7919bdd64f9a191fc3d9089d3efad1fa2d7dddb4b44a4fcebb2fcc6e63b004e04a991d7c2681038f8cda8115fd6359570170f54d55b02038329b670089193a4a4cb66b3e80a2479141b8aee936872ecbb7e87d19c92dc9e1a17e5d92c9c5072c59f65982c3b94f0b34992a40af4b99d2417344e2a1d4177dfb866f5807051ce53c9637ff207fb7df162612f3f5f4ae9df74c5e4167eb746cccd6894f4cec19c4e1fc5c1ae6b181ef340fa51ab5d2acd21c27a97bf076ebd1cce7d85a9ae710dc3ff9fbb585b889fc49481cf7f5f2737498313b3f694a89cc1938e3a580061c91ea5197e900981e310f14c6b4754a9e8c164697a4698a36053fded0d7ee28285b08b9e9e5746834d8abbe603da64c72cacefb47bb0d925882ce3ba45ca9a1c52e6d6816b2c480d8df5a692a5e01d67a43b0cedf186dd8086e9df2349167fc13c251d10c4daf740d7a0565111e73460440e838b56fd28dbc345e09025ba47bc38165a57da41ca1d3a702c52ccf922e7c81136749e97a2d6153c817f635b6c7035b7ccd2a1a351b2edf970ac381d9c7e96518854f2c4cdbe7a30072a57c59f0796bea2782a0a3069a67211c204e8ccd12120a86fb0186c08798b6a02f81d2f0d957d68ae2c91aad2ed8d933427dff8b820e344b40263a73d2fbf485f8d56ceb9362a42bf43a2997a5346559be939439f333348960b44a941850366ad98502523e04530d0bb48ab350781ef81fae007ed0140caf3ba32e93d2dc5810c81a1e1474a90d685391bd90bca39bd9b3e46ebffa722fedac57035e1ce8900de270682e5f889fb2949d2eb80711da6848e2d956746596cfb18fbce7c73dc3991dfa6035c76d89de6297147b7e0176731769e6ad980a44b02e16de365ab90707ae40c95258eda2d4a8fe8e910f672d8ac78ffb98ff35fa6756044a7cda1da63fbecce696337a071528e9d62e7012911e914757628cbb592700959ee276ebcec0e866f552df3eb7d960ff806e150d10d590175e7bbb13f8b643ca6dc1b9a5f50a81cac3764ea200ed27eeb17976719bbbbb3bd9ccec9f829591857c274d5ad2561efd37df2452c95ae35dbf30d22cceb2fc1462bc2f907d01f357644154bfc183da4fc571c81bb7cbb393120159ff49c3ed89788d7598fab34c18f9d94a32ed7b1c16cdbe5d86883ad581dfc86a1bc6828a085bae1dfe2cc895ff12f470004b2145b15c25e20ff99836676d1ffb4ec5e0d0edd19221c88d1fe25b4ad6cfa298d98daffadf7fb08467b7d98d8651be1b57405f92c903273c6ef2893143463fb7013cc30b4b299bb24425312b9daf94639492e4d2162600fb2eda8d8176ee2ab23d355c10771b3d13711b6f110ad93b6f562c959865634199c6dce69323d5c73f1856a3a875025ebc4f9162af77f119fdab0eed531af28356ea45b8475f92385d59628a76e3bd08a9bf31849245f66fd0d3fb09898ed6503daa3b77700c7ec63f441928a18823b8933dc491b8a84372051205b68698c7545aec9a0b1434818b553d87601dcfdb207d521e8cd8df1d78296921089bb35315459b93c3bcc1fae8b1bfa92fb36e40aedd58291d42887c7fef7d2c0c66a4f289b681f8c3d8d2068720a8663ec9decfda1aaceb89d21d98c6139e9decc5ccd82b7878cf4781119f822abb391dad8ee5e0e33e46d164625e97f697c00e9debbf98fe3518f5bb5e9f059404341035e0eece705812ed58f719ee78bc8293803a93bdc639f308c691dafc3dbd6fa99920bf49628ce20fc3297ad7cbd5e0ff533710873097c9eb5519a66bdb6701bffb515a7565e8f0d89d5d9d3d628985e10f0a7de20535d5222b0f106f78c4d1710f519d5b55bc9b5038e82f58b4566efbaa60cd898a709546b65edb6647907e71a20da8849ac7ec386f5642753cd93be0f59b45ea8f76b092869dea9fc4a63d848e0",
"f2b134a3a41cb79e5083e45600d1e65524afc2984a8824fadf2e082d7de30f25ac5157"
]
},
{
"key": "a16712f81b2343bdeea8975e95d33bfbdbb1e9ba323561164e83a210a0d69ce408557467a647d0c46a8e5815f5b47fdbb7c882b264c0fcab2f9b227fed7391965f54424bf719d63f",
"payloads": [
"",
"36",
"7d2eb6fe70aadb64c6b37899632ab140bdc28afe51f59b3774302afa936057a00baa80f08fbc9e7b5a980c11eb07f7bc161b32cf633ba659967af0e7f0e8f6e78589c28dcf126afe4bc7e43cab4d2dbef0448de40ac62ab340e0362905dc4d431336d8a8",
"38b342ce23c6b2d83686f664f0306e38914a63b13c59a6cab9907853df04fe96e690f1a426900cd44163b4f3a30ac376b3f7dac6eb5ab73d3bfe429b666ec83e6f2596a15ae5ea59b6010e2e4d1ccf2bcadc6ebbdda0a532c7e00bcec08f5f6ea6affc208aa3cac21cfedde56553bcb08e6b05f0fe1127e69b785036bc3d4d60e59ccbf46d2c53adb2307a0d176c162dd7a28cdee47f6c81aad1596c9172988683124b7ec53e744a7886cb507271471846eeab7b73464bc72d8627abf83e5608e3b95b874a5faab3e833b462622c50fe2562352749ae9994f5b561921c7590e5a1871a2ef0301d460505952ad667b360fd7d23936e1f0fba815f7a0d3170384ffe8ed15fcdf53dfeef765564387a0b8c79e7c5467b3bfd2b490dd1ad3fe7d6ee588744ec0966f4068c433606a7ee1af894305faaab6d4e5b762b81491e5e7a8b37b57583673e7bd777d522a2de579eeae851b79d8d2a2a7ce0e56887e29fa83e5e8b82512d912446c3aacafe6941a4173995507029b478b67a276f5b4acef37994af2f9da25f57484e49a761613a0390bb3ba1935426eb883afde96083c3d464b25c7a8705aa26818d90cb194345df7f3feb09ec4c00cd42e7ae862471972fe26aa9b9bcd8508bc9727f107c10676989376bd8560580cc980edcaff4ce4e6228a9a37de8717a54c1f32d9ff7ef1f787398ccbed2bbedae15e32066ac5046321fe8a76a629e74f09ee9ff124165030ae35dc7889810f34cd5591f4745f71942a32559dfb6d87578e18e3ec69d9bb9a219ee24a6dc0a16b368917fa8a257d922fd933046cd8441d48596a60dc83957c55a42850db7b09d48415771ec9d2a499b42cdd6ca530a3ff5666ed5c607f43d299c7c0835d8363b196fbf00726339a7f24e117d85bea7804716b53e218f6b7898b8e7405932256cc82a8b7fac34b1300e605ae6ea3aa7c8ad4234aa40a881c2f6b62fb679183ee07c683d83c1098a082652546ee1e018868aedd098d1f33d2bb3c8811dc0ff7b1f280037a88919f555c0fa62a161ae01dde6bdb4405ee3bc962f8f3c575169ecfcf863a5591fa4e42c99907c159b6822bdea8404a9d2d8e92551210441c159f516be5579fa5131488f38d8fc8956987befb163df71b3799ee497ef1d56973401fd65f9972d34eb7e04d42cbca8c07178ed0e5c57a890f8036c1b097639ca9ff45064e70f7c1b553f75cd683c50198d855d84b1a59fd090ebb6c8a0d44a3d400ef7aeb2d3321b6e242876447b802f42716583ccfe5b7a21252d3a83262e165c42b97e021c207781ef0d1ed08e1f1f670e492ea84e86ed577e599571fdc197678fda94b9ec256120a7a277da0a847683591842918796c0cfce0d3b614ae6d962a41e1136f486965611ccb97a563e1d0480e78754b85fa95eb2bfadebf265a14d3ac2699b4c530933a3d64344020ea6e61a72c3bab7d9235aff99e81843ad468867a4ae1b55c23487ce639ceb941b18aab9fcbd0cf084d9ad6cf42d94bf8dd5ecad12152f42aeffc130dd4f5436831476e425f3c12d0ec6e14299dd78ef5c09907f5404644df6504d9469ed5e4e282e23b42158d57bc31548cf40f6a4e6fd0175474a8d2806a557ecf2836f50c102bd2c7373fd280d5041bd6bb390de7c6c2d8df6742db92dd1dec3b0057f8fe5ce7ea97ed5ab931462d5a008cf7ed2c4d4932008fa8fd2627aa04ad87e08451b50d4781076298881a4fa8f4258b6a3825e39fa4897c1a23cba24b8d0ae62b19a452f3531d2d5fcbb6b8ad68523bc4a0a8e7d2c7429a222c3cadc5a3bdf101e0076973b4425ca83610e47493fabdfa7283faef4e042c1cd3ab9cd3079605118d2f563843ccfd1e17d449e6b6842f485522554c5d339bb7c1b999b057d74ea9a672c74a1265dcd50b4c6eeff3343e25379f9771c422ccfe3ce367f9a616c25ed3c09e44ca7f51d0390431f5e77a14bb8354d1570e7da34f679dfa9428c0c231d672a18ac44e52927b398b14d79996d7d171e2f82c96e",
"c2c35b2e2ef981fe0e1d109c5ab2529319"
],
"frames": [
"a669f7d3f9ec867ef17049cdd4a9b4e2230d",
"24dafb5dd3540e37f1c33d555be10b968fac61",
"dff1cad064d27ba181191b3764901384f6234f02ee09ae06cf07352bd02c2f9d201c838226e4da2136dcfd63c476f3ca4aa921cd2a1b26103b6540f58818438fe4682f81f5a8033ce38bd943ac6402cf387f5099ba81098a97ffae03ea72d68c18fdf43c7a7546eacf4121882274b9fc1a4a74941655",
"a687df02d554cbbebbc102a0d3a11c5b93bb5e5052f04f190aee006287384a246d73f1911a8f6ba3956e7d3cab1e30d231a1053e28ed65d32d2e7898d378263c45877472ab366c60dbaed976c6b1554db4a401701ce7f91adca2c6ac50c53fe88772c3a369cb5bfd87d0a1350bbdfaea6236f729a1e36b2f6a60220109b39d17e8465b06d21cf64d7e40c387a8259d77b63723ca4560047656f5f8b4f33989cc33671d1d2075e54bb888f3b7f34d2599c8dd90aef2e7ead585e81f3b080d769d0ddf8707ebf716d9c5a47bafd7e98be1686076222d9609ab54076b14980554d7fa64839863d1ecbb7055e20588ebd32ea5efe06b501f875ea5e4796bdcdab4e1977fdef52e1c862e225b3ab718d980e389edd435bbcb3ab8abc3a066f1e234da22d89125a5dcde1c811e4522ff35d873037a13c430c83ae2d8d29babdbe189c44732c749a06b277588f13c41a711abdbe51b90083961a61206103fb197e83fa9e6c5d26ba69b4ff230b805470a11a1e1abcb1376e8bb3c7114d1154988d1526b9c9dcd8ffad79ffb09bc314d48a41281f234ad8649bc3938737d6d6a3e487d2671c5dbef4458965f63b8d681094d79bac3cd9c33733ecd3fabefceb976a10a96ad4a20a5593014b7b4cc091dd0637943a649b428e57d8c137bf43bd63b446ea6478cfacfab5863952420e2587e4d12911f772b2f3c7021bff0dd94b117a727f2d66c210465203a0cc29e8fd73ecdb580b339c86eb018fc30be34831992a690dba74171b8477e30ba4c42c1314a3f610cd94a9e14bc06c7cbb3ec6fa252a9dcd7c7c17c48e86c4ca2b30e9c0f6c7c33ce8b29b76afcf9b8368e236e2cfe50928934ce032334e505165d9fe68ba3ccfd2cf0c2c6b7d2b42aecaf0619f9d1067f507119d6b4a48766ac424fb57088dde9f9a405c2db710a9c816dfc740213678c91fa16680e707fb2ddefd6c7fb005419d261c463e2cd581c5128dfc049e775d1f05ba393748f5185afc7773c840f658f13f09f13cf0edf0c12b580fe7c83b2e70a879817f1d1344a8d1f201cd30e7d6a089d5c2b7ceccd1098adb51795a8eb572acc3417ed9d972af5038a833ee6cf1cd7e654dde6b115a02ed8a7974bf5dcd45fb04c40cafb1da36da4d03dec3e096741eb2ad4b9400d4a7e6d762029a613933022ed8bacfbd657afcf5ebe539a8cd9886a34b4962eaf4f405fd7e95a7d164e254a0e28e8ec22b13fa9113097ec8b25021910d9b6dc31cd9784d1f529af9ecfeefe8220d84736705d6435533478c2acfb276a23a314fcc3da05841b9e18c3569af5088c07a2f26ad18b33c1f7991997d9e8039bdf4aab672f1fb66f304b2653909a1ad100424eed8f6d465d9dac15be70a3002da2da9c24660f6ef18893a8e40ea4f72c669373b5a96812c0847068047ad6c75dad75f43adf74b2581c33d3e9a0a26e7f49d85a71938a4b312be4e512b454f8397931e0527feece5163e7234401590c68c90a6f17409fcefb45b34f5eec8fe99739c7b00ed5024feef88e940a55aafdd97f13270f5d202d60fa0d68bcd3ec132d876781df6d8e01b30aa2edb8155dd1dd89edc55740904c187245dd55b4757a67839f9c54a16f50e0d4618e34ab93f14255929e12759ec9ee42e1015223c24399a9c5bf85a31c2ab873c0f586c31adee5082195103c3bbf51e081311c6c2c1e480d620555f328e10c00d7d407fa8afa75e7f48b55ff72a784b170bb4bda8f5971597580a2aff175c66c1db613f34eaf1725344c1cc446b0401c1fb3c6d2e4981f688ffc2a1e17baef03ff4103194f9ab4d4c7214d8e910f4f04c71e5d015e0b68c948a3c25c4e59029855929fabc0461e57956e9b0989dcf8ecfa3bbf4c2f5dafc04f4750fa1116f28840ecc557fde9f6f07599aa625899948ce84b5dfafe67b311ebb89421bdb29e3c86352f6335ebae5c20ab67bb9bddc30cc9e8303f598c1164145c3538855599bff0a75e193c96fc32c27c2d0a0f3b3f24d30ffa5c4f3951fe4ac1c401af8e2b47271ff03ea2822e8e87b64d00",
"f34d5d1451743eb0787cc6df0a5e59c1c5c2fffb6680c3da0d0bffe71cbb545bca35a3"
]
},
{
"key": "c6c8148d9826f74b81205d5bafe41302e67e24fbcc3f23675ad829da0d69f308f38a6315ea894eb96a3d8f7f506150f45daeea941d3c937ce6ea39425a52ee05ac9cac581533877d",
"payloads": [
"",
"ff",
"9b1a5d353cc93990cd973bded57b6875e508dfbb6aac3c279f1c51dc81c40de63f5628fdb3ef549ddcbdb127558d718715cae236d3584c90046169205a28a2dac1b7229f2416235d84d9e2a3503bd69ecc854aa9ffd41af2d90b166e03c1a93b91917e20",
"5ace27bf7aa46bb1aab007a7c9cbdcb5a20a141cc9681f13ced8bd7a3b57cfa1109cd393a001a6034f33297ece3b13c1a6af3a282f7e1a4e74e4c7aee7706920d2d0a9dcdae6c521ef3e3e1ab425eb72b491d3fda337cba024e40621e2d086db2cdba8ab9a39c44028cbbfa7788d3b9ad97ca1679af63ea58ce04cc41a44e3b7fdc005e675496b4dd23f9ca6fc5e272557bf2b5ab35e4a7c9d9d531bd3bba1ca297d6e2046ce337047caedd5b0355469c2135ce5dc609750024f3fc77f4e8c2d05f85a0914b3dab838f78c2f7bc88b8a2cece63e72d797e045991fd3b25d39884d14acdc830f854515827bd2d2b93af5677162457c53fab2a48c25cd38e3f78e2c70270ec59693b865b73587d2fd914d66de8e7f9f76bd4bfaca86ca1b183683278bc18e6c10b7542eb84cb3ca545875d503219aaf1b18e58c2cce5080b88ff694769daea1e8180543d10f2ac1104d4d62113a747b58a134892cc43b37ae2576f421b7f785551459851d204387f791bb74e09f0ede1b42a495e4ad9ae52f205774b95c75483b8a7c557d0dd5a7df63bd2f35196b311e7c5f6a373f61574d86e730b1f1c975923fb15978617420ae0b8138f07674a22361aaa0e6a44bcaeba5655757e41710b115a72e574744f6e492411aa8746f73bdec767dc829fb0a1b10b82512b52466f34328121a73dce40586333dab0b9847840a8123e855394330ce0649a414962348fa0275ee7e2f464e519a698e909edce8af4c320b1f46d45c054f75fcac4f09fd9bd43f7c93d0cba52065d9e9623438d4a08701538b55aaa54c3c2976d38c8db444971f52c994fdb16481b44babe994efd11770f223f49bda09ecefaa499ed80dbd0474a52b82add17c0bfff2294ab72fd025afd763fbb0c3d358114189e59f7e5ae0efad35c551df3f7fd8b04700556cfb5b386df16558ab67a5d18ad3cb6f1429f3410850823a5448a5048e3bd75d2bcbc832403ff3715f017c5ea516c6e324bbf966fa6c9126e479065bd33a339d8a229894538db4745b7e1e132d049582ed5ea88d75ec70fecc4b59f9907ed78c9d5b32a8884b02d37aac19123d320495cd4b5629efff73e6063070ed7584938a1805171ee219dad112215163b8753800cae77d53f73878c7d3c6d0f3cb1178a42353ef3bb8f8a749dd01976e7b1e1a842744ed7d640eb9b14c9c1775c86724872ec9ceca9df45f2bd98484aa978002ef7dc2a9acae2c6de54c34f6553494dbd7021c3a3422eb75212f74cf19e0e61dcfcd4474e9cd80bb3ff99d7e613dcdd5a1a9d6479dc2a96c5266f2a4e6c707d14dd3285892210c3e24546b02f55dfb6fd615b4e50537b201a75401837d32b8c82af81479e9cfb7f65d9974704b9c710b890101d7eb8a428798c449fa3504d84c533078c072433e7cf95b77c9ae0dfc34da4104fe0323625ecdc38649a1babbaca454240ec00753efbe833335e36a47b498374dbbca61b391c4c8e85abb224bfa014301eacb61e5bbd5327ab0ef724d616b5eb34aeb8ed5b7861b3423c3dd91350a4589df76862d99d598e0af9be3031bdf7edc188a8d428e83b62ae267c0b19f9fd2a81fb34a79f3b22fb64e98b523408574d3fe42b55dd0f08919d5d2c6466a2956a9d36780a7fd76be0d7cd1bb221f6ca12710452d141622ada4239e5c33d66143f4254fd73f7ad22124f958602d4fc4f7fdf5b4a6b833172244180466d133f58bc5c07132e4d138e6a82a9ffbd23c9d75620f64fa953f67675c2c4dca70cb9e0ed95f79fd49982e90838ae0aab3228e1a2356626bbfc40bb25e69ee56629bc29f413bc0fa225688c46e6aa14cb72a60566699d6da0bab8594c76416c8b9d18967e2b4526b5920bbab73b6c56aeedb5dc12735b970be2c955eeb19e874b989d5a3a751fdbb4f6f7fe3e57d347a100bccfd4f1e4c11997a2b657d66c274a40297d313a2e60dd8298d154f0ab4d872970afe3b094a2593c5b4bb1a10f2b0b419582902237d5844708c24278ba9317cc70faf",
"15b6f07fdfac01203e01c0a67ac8755e1f"
],
"frames": [
"0f623345d344dc3d31904ab4a7ebe2d697ca",
"2b2365f9ddca6206851eda791e73bcee9de8f1",
"a0bef696a6647e2979e04c7ce203c0ca295bdae2d959a4cae4e8af52c5ebda06d5818df12465bded6e0771114ef269a3979c6da187aa045c1e7171f81784a3b490bb4e9f714b9f301e938634c56ba71d36aa819dc45fad6d93e879ac2d59a8f1a4304c4649ec5c8aa19a66023ce16971e030c5674026",
"f74c933b725482ce9742dcd13a431c2b95996e090ba620fe7302058def1d21bc2e53bcae67a1ada0c4b68a1bc12aab890be0f76eac92cb9e7037cccf999e9e400ee0f3ff307953918dfeace9e6df2e0a0a802e5233edb3efab22421593dd70e4eb46578e0cd6d89e59d365c504d3abaf28bc695c1ddfe44e20ac7016c5f4a07e4ac3db01d2328b6b13d6d13d3ce9577ac1ac1df80d620ad6fe021ca8f84f73febfdb113a4850759ca8822f9ea1dcfdc0e78a2dd1b4089398ad4e3135198253f1f0c657a8d97f51e0364908710169888631dfe635996b16729ad343597d93250d5b7fdd0714992c917fefc3c2c2e8009a36b47b3fee549bee684bc3f0cc1499f5e817c1d8dcba82543381b3bdfac88751324acda4e32c9ad643e10edba112ef4f0a215ac975ab317649236c26ae26af8df8eac275ad39a1be872e769f6009fe50a8ef262061a6983107835c15d2f0589e5eb0b21224b5f7b6a1f46bba15a5f84fb98684db4003dd3a903d4dc3cb6f4268f2de0b46d689538ac2a7649a6f7a8865b116d2851994ed936a31db9a9a335fc1d260d3fe0fdf4cadb895ebb4d43208ff81dfd5785beb61121fffe0ffcaf8ce0b326c0bdf39b601fa45b38a0e0c082cd769aa5e589d8737096522a4a366212de84b4d5767c9ef3d60b2bd8921c88b4ae84555272ef83e8f2588696c13297f2c4a7590295fa4020601c7fb93b50336a37c0585be4b201fe94d951f04b3ef7acab38e7792207da550772e711f4920a71cdb6001c191acb15d2803aee9875a96f9f5a9e3c9fa171005300ed5a96cbb8475f0a1b2a1a7336ac4d8cdbd3b76e46c62a12d0ba333472f03ab04dab523a5f366e20736fc70ac83754d4ad9cb625e7f2bbaf3f9babf9cdadab1f3abe80a259f52451a4c18c2c32e5a2a709d096709e4e2cbca7ac8622fcaa0541a79149befca0e285ceef16cf6fae42e927b1af0357784e801c5666a23141a48d8cd11ab1321cfc555b97121f07c9741901326ac2e0d18725c8dcffd44e18fce4d941670bb1c806ef4a84efa95489eb3afdb4642b203b441368030eeec2fc1b1d78f16a9cdb949cda7de8f71a4ea6cb7b96eb10a94289c5fdb2c532c871a76ddb2b70fdc3b74a37fad2beeeb2a099c1c78bd4d56e8b69f62ea001ba7f8e125408ba2eb5904020602c36a269c370c258f5fd7aa246c65be14b9a856b4fb29715a247a48b8bc99c7c3b1677de1f736f471fe77423541099bc2436955963263da8c1be257f7534d80d1724009754dd48265a32b0af4e46cc8b1a078b8c73802d9cc141a61a49b9325611f72dd47a86afd252638b04d10080ad79e63dc808aebf059744c005789eebf667fedc6ce9b71cec57ceee15e530192dbe1fa7563a134f104033a23a9d97aba0f59e22902076c080c38b627e7cb9058f4c738c5bace4e0a35749fc0990b5accb867ce47c1cdb523eb7bdbae240e4da7c1191bca24c7011962b2456a068d09dd2261be3c9d729e63222cccc05481038252353e99c5d5f3046b8fdd7ccfbc488f00108340a02bc9c8dfcf4344f3b257bcdfdc7adb028daad69fc8397570537ab041bf4494b61746229ca5488157acf1d333edb5e9ee6fd21da455d690cc96b17a39b0344ce808230067b0014e4ea71f0e568cb57c90cc82ebfa360270e46f0e0a13551622aaf957f959a72e64b6e7f4def90989842550f21f4632a42590318b82d95440307efe16d2648f75c74af2ed729dba8b323bc1c82e8a98d1341b9ab69c278ccc099486b435b9d7eb59529e7aef00f02f505e7a381daba5cb7e1ee238f0a709742c5fc3c0c5b87989d559708b2df675d3cce68b556bc9c0b1754fe57eb0b78a14be217bb769f2ec576c7c9de0de3f198dd4fea73d331d21b35860800fb56f25da9e0fa4ce889034d13f8c5f0734b956b07893ae0ed1a29878d2a6d284926715a83281e08515b228d54a456ed6e7b8fcc295b54c1cffce8cfa252809ab610ec4c371b969505449b0437cd393325707bbe0355dd32157db1441cee809bb1cc403aff2bb66fe862e",
"d00ece49c2ace9bd596c38ae87921d80f044480515c347d9c8bc212df324b5c0250b75"
]
},
{
"key": "433b4a5ed16e3157bfae9ec715fc380fa5ddec01ec6e116073ae0c8cfed718eb0b23c3aef8f1611d9c9e5342551bad306afa6d0fa478b3312b983f5eab94ccd485ef53025e1f310e",
"payloads": [
"",
"d0",
"b5c87b404c2c0ae69b1ef5bd417ad8a99596211d7d638c3768eec5f6fa82aba2b45f136eb4143454f45d8e05e46e184eef01ba834884f69d98aa16d28f9b8db725c138c87356534530b5ae0b0efd7cc0c5a032a6a1f676c944a5cdbeee97346195708731",
"67e9e2243417ac27f83107f1815f0d8ebce0676379ceb84ad8b6cd362221541c17e427a49c94588f2b70d4fdf3982eb225b6be4839009e49b7ef4b474547c0e8562420963e2a4e8913496dada1974d65148a0eb24dda5118a1c1ab10b4e861841700781755083d9ec75f688b49b9a7ac4148941961851d0465d8aa43fb38855c253366b4b7df0c979ded011524f2c71a378049458c2a846eb23d3051fa99738521aaccacc1f36a89cd64979cad0b70557e761ccd023e689415a5ee79171754afe4f72aa73d2f66cf78bb1137bf8c03b9a6abe59d464152980f726794fd236d23ddbc79ed1c20f1b5544c47985a7343870770477df879760feedbf63aa43342fc61672ed4d1ad8a1828a64f8653b8bf86c13f4371c48a98d7289229a29df2141350f79a8bbe4af8799e0f4e32ab76dc472d3333ba19de650c992f32683df5a4b3bab91189a2f1465d0a5b852d6c21842124a32e84062b13d461e0867ea26ab51660bedbb6c99fbf0e9aa632c826ae4dfe0e99ce8b314033fc44675f48b4cf40174cc66f9d2eacdec7414d648fe4d14fd39447751af2a0100ca885dbcc2f2d47d83a332e6b804332a5b92fcef8beff5f3434adc115b5fd7525cb77c449db9707ad8f357291d041c41f1924992e7702fa78475c736452dc795f7b6518080d43de50596fba616ac4ed62c37e72afe7dadabb590fad71c31588622dbc32dbc048e818a439714c698fc752345dce4ec6b304650e937f80d781db264c6b2ecd2f2831b2ebafacc954aa0a558fa0305f3ad422cd73c84909e14478deb0749ab01a042b509484a3f573c35b0206927823daf08cfa6b20e0df2e49585ac80fb9254498f67a66058d7c137372ef22f0fbad6f67b30790e2fd4559d2ed1c3ac0a4f51044caf016eec3317eff194ae29ab6d8bee0e10c868abe9fed948b57c0a0b41c08118a0e182a4ae70c1c05432235c130a038450130bb5dc4e320e68a13d9919b3930e774dcbe52082c4ee73b43f35cd06dc19970e4f415cb463707ebcc39b0ba52daa8ae8b392ffd20475c30efad44f6dd1df886130c15202b73d8e370d6db8563c48850316c62d882e3bd428d0bc64f63618ace5557336df9845044eee98ae454b7faf1f56d5a04e1030b5041231dc09eca263ab40e531840de3b981dba86d85fbbf65eee153cc70a24fe8cbc94707c1f2c59b3928d53de4d827c2f1375fc5966ae1ccd7d46950471469fabd7300cba37254b5296fd5bf6177bfb1d10e00404df2cc6e575780eb5802629181fcc0fc9de8c107a590e91b4c09b93348ea47a9b05588f3e220b98c2f0f9051618741f798b0244a7b60145f95b2a958e1f6fc7b47ae8a54f5aa8398a4907551f662e927f847b3d37955337ff5bda15bda2203064b4048899ae68a36cddb5e9d0f1e851f52e94700029b4a66760e1a2b2edd3a856fef601e341aa9d4d09b72abb4701ad29b7b1fa8140f6726fbfaa6fe6852993a1b0b190e0cf1c4e7bffc499964d7918b27a9e99f76f655448dfb81874e8322ddb1fb7feb20382c5bb70f8a7de4ba0c597cf4b3f6f3bbf3d60cd50c95134aed4d00d013f477548bf5e8d2cdfa2ada7e67b3185274ec6264f63abd55ed5d2c2df23e144a7de82817ec27f31a86790736b4b227440d0cda9967b22eb1ef4c28cf73612d59638122abcb1b67b3147fd711e78f5660b41b472f02d8a4919fffa19db9009204b4dcaaf8c94c6942792f260446d67ce098b1e1b3a8190629bcd8228cffc3b47eb26f83ba725273050c8295971f61178102efb258c5cf4ecd7d3e2bcca72556d6bd834b09ddcb8fe514a4bcbb1fa6e06df3701939de9e87e163d572544acab1f73babee18be065b79fc198ebc33056a0af18beb3977077a06afde2b218f7e4a7cc944defb4d3d2993ff9ffd8a8b93a937b9391023acd246aefd41c63425fce38d7a04d230dd1a789f259d70aa7e0f0b3bcac76c3e214f68860aad7dc405fe69a7bb61eb8dfe1213686266edd01d6b8b364c1d2f0dbc8c0cd",
"c3c81651b60169f8fcaf8bbc3647c717a7"
],
"frames": [
"76500983b5232edd54477358cd6d1b18f0b5",
"8dc726a152f2a455848dca7be30014ef82e04e",
"9f11db445b819d8002f2221b2d25f0c4e809d84124570fb2a581c390bf1648db3965cfaa254ba36d26bb77da7928cf1786155397dbb6917bdd9e4b7b2b25e41ad8d23bec0da5587e6362783eb352025829a21b1e9403de13b2991b288d08cf0ef6f75c1de06ed51f19d1c99afaf55913f5b881f666bf",
"4e33cc89d7de04b4e61069d2106c1284bf1bdba1ee6eb72e27fa0e8dd36200260ab4dfd324bec032d5f7afafc7cee14fb239f56730a845d0664fdc7b80d6b451a1a1f3954351e10d15e8c7977338e9ff3205ecc0b15ed0ec177feb2f5d220aebaaf601c54463752ea57ab29a67e211e48fcc920827ca19e049070e7111aa42753b417b397b3c148f11a8507c7e20cf19c5bac2733b0ab2ba31fb6264609298a5f44b5d63d378980914a167e955302e677e59251afff6c6b3eab68d4e9f0d6613bba1781b3dc4a77e81befd8d652e83748f76dd5d88411e79f79ac839a0b2c589d2d3795d89e31a19474b6475c320cec0cdd4369e69fd9886ac3eefb63ad2e92eea2cef3b4d2c16e0ec7eb8bcb610248e499063dbf5fcd741a2d4dc8dffb798f543a3f15d0410cce083e535cd0c960f762fe418c87ea638abc1bcb93a5aabd2daa988ea9d7edaf9f3bd09eb204babde14527e02ab98eddf36312553d1957e3cc977b532ed066c74f09bb8b8e9b221a27aac5408e294154022f0a98b382b2e5a39bcf16aa5e293a6e33296e596cd6bbdd1a663dab6c479c82d4b0f181cddcd3fe1eed60abd9e81c5b0ade714e84df68057c843c366cdb658b1526179cc5715fc506af1d589e45c40f3f3ded073c7638a7c3337cee61d61ce47fb2cd8cc3982ffa1c7367faccf3e98eb4180a9cd15c80417e4819df9c15ea9b62b8aaa2af7b45cbfd38542b2faa2ff8fc9e45ff11155dea7cd1edd760f2632b63576d06ebfc26b266c4c858fbb02bb810db6e568d62b27e8864975b4362ef2a523971474b6f53413d4874935a934a5a25e3d2c78f37a9f80989e0be67df270974f90072ed913b8df971201834b07eb0c9ffb4e7cc181ab169206b897ce38439671d22429c3f58676384f08bc2d01e1bb09c05d35fd27283506ddfa42dbb2ff0ac8b150b3608d37a3bda551dd7ef011de692a6479f15f60b5ec424c65fcc612e7a88ed398d3c26eab2a174eb69796c324b7c6eb69af27c9314538cbf75b6149bc8ccdafa1efbaf63340115936162c371a4207cae787533e87d263b25746a507e1828d47a03c514baf7b5a98cbcfd699b67240362278c81542f057986bf16318f3ed5034eb65adbdebe5452d052fcc8a769aabd162e8f65ed475ebbee208c54b0b61107b382497aa9ad47937ba098780ac5aab6816fd291224993ea1139526365b6a1f11127746a8559cdfc9f6057131687e6f5de9e8fb227cfccf49deb698938e82ee60b6e9901fc6fc6c6b4b823e8e1c0e64533d0d8f30589fbf596b076802c6289086b7a438ca8632b8fb9bfe69f7b5b38f2b6b5c1cae4b71b37a108da35845368d00e8fe90c606431c61163058943025429ad9b8ddb31a2cad541450e9872a6a32f96e74cc3f488fe3f5b9fb18079c7123de1a62ffe4868f4c3b972de0b1911e52c057a9598054e996108417d3bf3c5aebbd743130590167464dd09cef6033efcd32e3dd515163192ecfc9b1e99cebdfb0d8b6e519b6cf995c88b905555057babef7ac5b77cdcdc278a069ec007617005f1f6c072b375fb4918a5f16138cc03e2d8995d8143fe493c248dede85943b6b3f20ca16618653206719386c498f07a6fe1bf7df350a8e77b839a7f891ecb15f8eeea42e00f5a3a4b7a296b70be330376ddad41d48810f23e080edc9e27d55afdb3088a20248c5f83fc6911cd106e9e7389e82da214b76f794cb8f0df4f9cb5d9ae2211c295f39bd81ed2adc876834164740b8823a38ab62285f6d8df062829aa78847325d2fb22cfbd33e13202226be487313a75284f9f130df921971074d5f72f031b73694877935dbdef482c41a6f98e729d900f737cfdb4c7a2a74bb7959a220b695b72b9f4e9c19acf5eca248562fbd16a3fb980f4516cad24b8d82b01cbecb20d476d54902ef582093bbf9c2e5c1ec8fa35f448f55ac16c0873a9f1f67d363e3e97c9a14ba457a97c57f7a33cd790b26e6c07111e197df3bac9b8f1beb8ce74e3370d28cb8fe70d03ff7d1829ec0d07692863214870fcf72799ac8b6",
"502afc7c8b1155bbcf851309723dac55e8e7cb757b6a84c26b73a2674b73be0cf6dc3f"
]
},
{
"key": "c165109c7d9aa91c7fa1d68c67f9701a47e995c1cc69eac74feac8643bc83d0b7faf1ad717e2c9d70d6a11267255718883957fd0020be9188481cef6bea5db837ffaefdb9085d731",
"payloads": [
"",
"c2",
"595c40c6263d02c762e75ee79bb06958405c3ef86c751ef04d0661ff635fd15eb6c72a14636282569f3a2dc542f80f804ab13cf3ee15275b0ce9b0985c045ab13d692fe7a2a0ed7f136bc5a9c1e3e89689991414596e76a910d7ae012e408452565b9edb",
"f9ea331159bc82782132456072b27fa5d5bf379a2d5d38f73875312356d191f86c8b20d7d5911c28c5c2fb2c67e904514a0c40343a69d5846917f0089abd84c086584954adb85acdfddc467b091221c607acc4856a7ee912f53c12a8f0c994c3a9d37c13f1c52e47b133f22cfcc02c52ebafae7959a523cec2bacd9c59b3715a82dcac4bbec9a7d1da0f5b110aacde0f50895a6c39f548a6e7d5771de4b8ebe3af268e0f44b28767b26b73098ad51c0ef4145e3f14cac39373bd91c8440d62a0545ab2fbc0344baa7f0727038e61c2fa5588157846675f717996c8bccde83b0ce7f3572efa1d1769a20d95fa83452406f0d04fbeb16736a169e5b5dae0ea25505420cdecff24f0f174b7ee7427b8b5bbd2ced21774bbc617a515285289685af77fd1c64b64b8fe408ab9f5a7c1d5d053ec33c8e6d5f5f65d90ef29c44da744c771bc85ce2388489881cdb9d05238064e76c8d9d965ef162f092ad49b994d3628d7d312c05f40eed39d8df3df06476677abfe48002a97cddefa5b432989a786d89b20423b04de88f3ae143581cf0b318294aa215cc81a0058034fb8266033819841b95ba06911737c7ed3692125b5a64816a4ecbd850b8ede5c6ffd6c8a735986a142a8e048f71c0d14c2bf96430380de1b654e48c40a6e0c77fdf535f0fdc4704fdb2609774d5ad2111368f75d8a4afc5ba87becbb45532f0c20d08ac1581dfa76ff6ad2fb99fa64b13ab3095482460739ee544def1363571da60a24d178d7937ef07848975d395674527a05fa11ff958cdc9172543cf028aaae7f2334fdad0ff46471a43a90ddbc5fbc671534869cbe3abe791953ba12e1ab13403000ed033f49767aa0a723d854d6eabe4143ef40a23c8cc1814b0bc6528d877e230a3fa13477ad8722103107ff32f3938afac0eccb0e83805f7e63bd5288d5fc2c34c13a00bff09a439b3be8fefd633d43cace9f5202096868f3d726529abdb59ce804e279643ba71e831ebb0aafcb5accf908d7fb66638c3bfbf02db0052490e9d74d841f0b3807312883bcbedb388937a41b1045a700cce586d19554c873eeda3d9fa2a8931a68ca923e76b71fa50cfaee46465d1dd5a9f337fa8b924855748af3bd74da655621c693d36611fd65fae4fc59d7c55043839af5f15e64e267d9f638e65f8faf805973051dea972945f5f765f21b78bbe7e2f6e8f54a6489ff531872ba4a9c513e190e4786a6a33183b8d0286df92b06ff200d53a136f124c0656097c89c4d16645416f00dd657ee33a76117cb8c06e00b27f6321870c4d5b1927e01043aea22e8e5812de4718145d348cf42dd455cf7930e2d803af7423f9e1769d71acd0435bebe83cfaa554800565f1694a7dd015218ac8f297c30b32e59aea2952a77c582baafd67cacd315ac21ba3904a3c5a0613e47debafa8dab945445a615aabbbef61e716b5267ca235cbf6943e8fab317410262965cb1fde5c544df9e851b052f93fd60b2a1697fea1f1c00def704db80907e431b09379d174295d1e882ab3b4dd79d98ab57e5edb422a1c1a24ede17f14f72b6f694d0b5521e958c70b2aba91d6940391580f2332629b5a5b6315b86b21ca15f525551c730d35b363acbe355d6b842f1ddaf7f279963ad19c0c4ef3f87b0acc8282c97db13dca3bfec3fea9b4749ad8b3b6c390c471da6bf96eced82049a29dd3972dbd3cc4e4540b8ebf56aed7ad8a707f078b60d07980bd03219aa0546894107d132700b094f28b1bf037aa146a23792fd3ad8c37bd0822bce341823cbe6333523840e19199fab9e824f6937aa986bcbd602c9810c18b4a9bebe42f82b6637df4024eb6363bcd02f11b181b5cf87b18a20be766711d44c1262602221b9f41a4dd11f00ac60a838c6263a18e019987ea2637fb280579598e9c18d0f4e96fbba9eb745fab6c7356e03e38665dae35463d0a13412acf6ecad3f64ef19a2210d03507cdcf9c821b314d67ae5fc9afc0e7f059170070b608a6ffb4691a7d689db6f7d7fe2",
"e1a83fea0f35676f02f2907e05ab85fcfd"
],
"frames": [
"c4ff401797b835860e342093658e3bcc395e",
"592ea94af85f2a1e9c2c629198200b0a49b40e",
"c7bb28228a1ebbeb1188263491379112544b35820a7d813babbda4e06fd49da4c2ae23a25359061828c5ab1ac099944dca43e2015f4fa64c03adb0586db634be8ca3f104354eec90db5c5e075492b03e7cdcbaaa2c752a8497d319cb7f04be364aeebc3ac3de6a903d2690c22e2357a04b74062adfe1",
"b355796b6f36bf80b405f175b67c84f8de1a104edb60b45db34ea6a33733b72e7b635f7d2f5516724468820b060b62840f9475f5dd676593ccd9f4480d567e10785fe52519f1fc79704d17590835c0c02a1e35919cba62abc9925f53fd7c6787a1487cc5027daabb8ab67fd2b981c37684ae9da10b5f575696f03b1d88210ca44380fc4c042b0f5c4dfc08819eeba99c6f8e665233c4ac70c0aa3b5dea349ed636f658e5eadce3d4d34f3fdceb077b7abd176e61ca35082faa0ab8756ef3523371257b2cdc75758279ec42c30fb3dd5a5d8ca0ac2313676a374819b16e0a37809112dbce0018db52790e290c6a695bd9b27f279a08e259af9692e10c860f12b7a780eb00d2a4804f712a8ce67614e7d16d57366faa3679c222531f98ff36b1935d2a8fe741d58b9745a9b6600f161cd776117340299d9e1549efbee521e5c715306869fee61bb24b97bb4a787be1c02c31990278993a261ec88a8de99bbe7b44fdca41440915c39c2aa18f70f8eb8c6e29e3f6dd22f35c8f67b7e89ff0c7ed3274cf2df8965073696bbd0ddd76ddbcd77218830cff978bdef234eddefa0661aafa826c2999be6fad1e4a9be9576cf54376e8494d8dfb65705ba11c5ee16b8bd49f03e7321ac2ee90f2e5d072408111e8f8ebe3134036a843f16876416c2829eefaefb5dc5ca8bc3de3dd11097fc7ca2b0d41177195206b85dcd1bffac962eccab98221ae89d9a547bfc3f8d1c5134399be5c5add83a458bdb30d3e0376d53a8d2e19c924715b50facd786d4b2d9e40c140b193d03c0fafa63d75aff949ba0859b589e3ca04031c4540681cd3d9ac08d322a0b643ce6c9f7ac6dbb52c1447f47fd56be6f95df8cdaad95ecd3d6823846bba6bb4bcfb4e960a3aa9fc39302520d5f9fa97277c33a5a6079d2e8f6ad7242a6fd8c34bec7f9380665d770db332939d222b8746380e5313d65a4e22b14eb325b4aa5059772536053e05060655d593b1db3c5cb27a0ffbed5021540f3ebae506a45f3ae5c9524d8251acf12951c70a363e4a49f78a014aa5b35f416e7e5dec7350643f76e1e489beb4aac311c76a4bd8560be99048465354cc56bfb6a289514e4f8b91cae63a9707f70017d98087f26ae6d95ea070cc6d8ca5339525038b7a4b43200fe67743544c00be31e772616dc2052c646a217e1efae299d5111aa1fbb970840118ac4c98ee923ab46a0de4ee67ad46b5e37987dbaafffa9c48f3e09c5c5df4e7f255f573d8de5f63dff96ede0205c92bc5704d1e988ae73d7ad61684b1975e80c07f7be4dfe45bce3cea536a5f0d30b7aa37a3269718983e33ae2e8a29442f7992ca851d633a19c7ead6af7ece303f9b327cd4aca79905e105c83b753b0e88a3dd5b6794353b83ac67d49a3d85f18402dfbf56d1823355749401ddea1764c29763fd0b69b45f390e5bf5b69ff37f8c184db29b8db50712c48c8e2411b16815085dddb7359c2198e2b89595e6cdbbf2b903e390c7043dbfa1e17e19d52d0ea397185ea3feaa86fd2f73bf8148f914d0d131dac070ac098fb7a13f302e4edd3b6c47799e9eaf96781f98c906102198c90f9d369d3de41e95e25260f3fbee92cc88386a16bbbdf895ddff1ce0242bf24cb337362f7324a5ce6cff312d9441c23080937075921b11e9b70c9a02796c41180d10d5ae46d559bfaec0f519d16485039ffc864a916822fcdde93a3de69316c91070795bfd98d6fe3c47320366b94edba76e0fa557bed1e51d25544018a9d2a472b3441528bf5ec3bac94b2b68f6e55432eb1df924a0b0b1cf505233b0669632e074afbf36cd2a30af0392b80d4677d24509b920c35e815254dea0ba34c09b969b3c1f7aa2b97b9a5d9f9b51e4bae71f0ebbc6eca8b18824cad9bade04cd009a37e2b467a3fdc57b343613e2fcc55fee034f5103b8bbe5c76548c4be6f51d9e6a5b5da91ab30f93452cbb79ddba22fdff23c7bf0a123f650af6dd4808ca891379b484f6e37ef7597355b6d20cfd4cc40ba583a60c8b378b31db0fdc4b1fe419da5509a8d6461",
"47da44707c5a2d12de2dbf31caabcdf0b5776e0ff54e8ec85effe872a3813fdac337b3"
]
}
]
}
File diff suppressed because one or more lines are too long
+567
View File
@@ -0,0 +1,567 @@
{
"note": "Go math/rand semantics over the obfs4 SipHash DRBG. Each sequence starts from a fresh generator on the same seed.",
"rand": [
{
"seed": "05f986cf515f1786b71a6c420bb3fcbcd1bcd5c366eea72c",
"int63": [
"180037798733823549",
"1023797179738791847",
"1761865624249628279",
"8192978223349593882",
"1349136184594006180",
"8591953938801926553",
"8698444496158717931",
"3413277081198754956",
"1207130668310984232",
"6726874423714744141",
"6546122928516813399",
"2707351428806462127"
],
"intn_100": [
16,
56,
7,
93,
46,
65,
30,
99,
10,
1,
14,
75
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.01951973725167195,
0.11100031264573305,
0.19102185374389763,
0.8882844788882058,
0.1462736382315626,
0.9315415126344435,
0.9430872419979861,
0.37006824267306715,
0.1308773693056648,
0.7293291864228701,
0.7097320700455102,
0.29353163007937005
],
"perm_10": [
6,
9,
1,
7,
3,
4,
8,
0,
2,
5
]
},
{
"seed": "8c5cf36e1e8fa723b348d6b445fb506eefbd3b9a0ad37dce",
"int63": [
"6480070349799675829",
"2016991850649746136",
"4672302796067101900",
"357010723544883299",
"8249026412712778780",
"8117261994706136172",
"8524685317237317809",
"9067896476093263171",
"7204963904149063477",
"5918189176239140632",
"8296980046950967925",
"4879373786393850965"
],
"intn_100": [
50,
10,
67,
36,
29,
34,
69,
73,
83,
95,
4,
39
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.7025706351111711,
0.2186826946360013,
0.5065720842006048,
0.03870718020679843,
0.8943612357553502,
0.8800753089294412,
0.9242482340703981,
0.9831433059253966,
0.7811637517558057,
0.6416513562058674,
0.8995603791973122,
0.5290227659577035
],
"perm_10": [
3,
2,
5,
1,
8,
9,
4,
0,
6,
7
]
},
{
"seed": "4cf9469006755e56a425a5fb84c2ffaab5f9a61b9ad1a34d",
"int63": [
"5542255436954797446",
"6078982071921011436",
"5361671747467629728",
"3075746401306639186",
"8472145921656303700",
"2079445698696703736",
"7921987114482477959",
"3993010511109309647",
"6498441858198684587",
"6246705345307048144",
"5806817419838080090",
"359784335928015979"
],
"intn_100": [
0,
8,
84,
14,
88,
80,
58,
7,
0,
26,
37,
18
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.600892538521599,
0.6590845568877194,
0.5813136156758555,
0.33347309303111305,
0.9185519013873971,
0.2254539544092604,
0.8589035639924075,
0.4329230670902168,
0.7045624780429752,
0.6772691506258715,
0.6295764061815118,
0.03900789586394095
],
"perm_10": [
1,
0,
5,
4,
2,
8,
9,
7,
3,
6
]
},
{
"seed": "c16432812f8641222e22fb598c3cea1f49f421f0bd9b6a16",
"int63": [
"7137948479543349739",
"1068628927803704967",
"1857275370826524684",
"7532112879535655864",
"9005458455971642079",
"6903441880655607317",
"1643616422352972231",
"5572956403993938525",
"2870650832098737127",
"2674433592483187385",
"5268406440076844205",
"5034870401071075879"
],
"intn_100": [
38,
61,
20,
89,
88,
16,
69,
26,
80,
94,
62,
16
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.7738979248610502,
0.11586098051056322,
0.2013661991953939,
0.8166333147398607,
0.9763737622192411,
0.7484726684634226,
0.1782012495847945,
0.6042211440377233,
0.3112365868608772,
0.2899626711138484,
0.5712017707867937,
0.5458817427024224
],
"perm_10": [
2,
3,
8,
4,
9,
1,
0,
6,
7,
5
]
},
{
"seed": "1272fa54b80b693a1526e76aad73d47ca1000486ffc8e2ea",
"int63": [
"2266299523755372919",
"4962372238692395965",
"5557210371389255082",
"5217975692527489687",
"3870789316561968530",
"473880929066103316",
"6692075858658946530",
"1539140837069603787",
"6923397241901753123",
"2001026984266605650",
"6558174712351968716",
"1767308011536092577"
],
"intn_100": [
76,
22,
67,
39,
68,
1,
29,
51,
36,
0,
39,
61
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.24571268671584393,
0.5380214761872052,
0.6025139557619207,
0.5657340581814859,
0.41967181862501673,
0.05137827327929184,
0.7255563184395827,
0.16687398393120223,
0.750636233065003,
0.2169517803543971,
0.7110387270671502,
0.19161191855584686
],
"perm_10": [
9,
5,
1,
6,
3,
0,
4,
8,
7,
2
]
},
{
"seed": "9d9eaeac852bb17e0f57256adfc4e23c97158a624e6a86a1",
"int63": [
"8991812859880464258",
"5864497437943987359",
"6553513902164822514",
"7605031751507615879",
"2862314111135425419",
"1323727665288322773",
"2767553488843912858",
"3086215113452383763",
"8999580915650212647",
"4308430354182295929",
"6922747770517395456",
"6692310895210159581"
],
"intn_100": [
75,
13,
60,
37,
36,
64,
60,
51,
17,
12,
19,
53
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.9748943037265496,
0.6358300862754546,
0.7105334010141056,
0.8245391946805798,
0.3103327177628944,
0.14351884104847643,
0.30005875050744074,
0.3346081130762672,
0.9757365180207046,
0.46712095500069367,
0.7505658172364142,
0.7255818011535266
],
"perm_10": [
5,
6,
9,
7,
3,
2,
4,
1,
8,
0
]
},
{
"seed": "07a52011ee7b7304d72a28cab672806d997f5b99588b0296",
"int63": [
"3772413026592079685",
"52919815567231157",
"3968348385632365615",
"8922280692955313603",
"3165629534368815027",
"1053003750036652473",
"7276818834677432211",
"2207120645876153924",
"6837452092266243048",
"1309745529840857675",
"6794975952186715247",
"4151696207094747609"
],
"intn_100": [
53,
54,
8,
56,
62,
40,
13,
20,
71,
94,
24,
0
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.409005839894375,
0.005737577900552424,
0.4302491940881955,
0.9673556110827622,
0.3432182418446945,
0.11416689534251216,
0.7889542789340709,
0.23929649991965357,
0.7413180412700618,
0.1420028948856636,
0.7367127689347596,
0.4501278047231954
],
"perm_10": [
7,
2,
5,
1,
9,
8,
3,
6,
4,
0
]
},
{
"seed": "12f82fcdefa04a21e7f23985b765d0d17ec80f9f7ff3909c",
"int63": [
"4884570228851776789",
"3021910336018798234",
"2485106168582921328",
"1400166114474197866",
"3736327909890935317",
"2255434195930023737",
"8487819480951069098",
"8944001114665822912",
"6338008241217828283",
"4308836362553729409",
"7018135233767328510",
"5390649060585650388"
],
"intn_100": [
30,
28,
68,
77,
32,
95,
72,
36,
38,
43,
43,
90
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.5295861653779114,
0.32763617513679816,
0.26943575068347314,
0.15180631431535127,
0.4050934837021976,
0.24453466551254285,
0.9202512320911935,
0.9697105439233459,
0.68716823043594,
0.4671649745165292,
0.7609077467247601,
0.5844553422593906
],
"perm_10": [
7,
3,
4,
9,
8,
6,
5,
1,
2,
0
]
}
],
"dist": [
{
"seed": "1e87266907e9cf536ed49b8b1ff8c603df48465d98d50494",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 631: 0.921909 1330: 0.050197 323: 0.022911 ]"
},
{
"seed": "4d5d5e8b054319791eb2d17453d380b706ef9cff36300f07",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 1262: 0.717798 1120: 0.429891 282: 0.122173 1361: 0.735515 509: 0.131023 702: 0.497105 1152: 0.473666 1112: 0.708108 576: 0.689546 907: 0.487596 607: 0.644117 335: 0.643810 758: 0.048144 677: 0.145764 119: 0.722769 596: 0.530116 1385: 0.583168 1130: 0.525135 148: 0.852064 1110: 0.630802 1372: 0.376404 632: 0.715470 574: 0.262976 987: 0.922780 39: 0.673047 1004: 0.820344 675: 0.132190 227: 0.397274 1409: 0.060960 240: 0.196218 1140: 0.903684 1175: 0.848127 188: 0.799259 271: 0.716531 928: 0.841572 871: 0.557789 408: 0.684364 342: 0.387401 589: 0.205922 815: 0.994212 991: 0.625443 360: 0.618060 388: 0.470400 1200: 0.313900 383: 0.193660 1027: 0.987201 378: 0.370931 1022: 0.984187 346: 0.360472 1050: 0.867503 ]"
},
{
"seed": "fec6b77732e0e7fecfa2498a4c8639272351f45c830cd02f",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 1029: 0.923662 989: 0.065330 ]"
},
{
"seed": "2eee8286f5f9d4229ab261181098c58910c65bc110639165",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 170: 0.494179 706: 0.986113 1224: 0.275960 1378: 0.603452 404: 0.935409 463: 0.976579 987: 0.578662 1315: 0.025583 267: 0.671236 1181: 0.686961 297: 0.390292 411: 0.623637 11: 0.488163 32: 0.784112 1061: 0.699220 514: 0.989684 647: 0.474383 68: 0.162215 9: 0.055838 1014: 0.255816 749: 0.717501 277: 0.257325 303: 0.685037 517: 0.791158 1287: 0.712921 358: 0.647147 804: 0.163843 120: 0.321571 933: 0.323333 1191: 0.450765 590: 0.426373 993: 0.390693 474: 0.727644 551: 0.266102 76: 0.644561 256: 0.931082 415: 0.836679 535: 0.202349 988: 0.693359 877: 0.217575 683: 0.991575 1353: 0.740492 166: 0.614490 236: 0.944088 713: 0.712298 1350: 0.599826 776: 0.800988 582: 0.522193 947: 0.394854 781: 0.920032 132: 0.155250 157: 0.644944 1282: 0.409974 838: 0.324449 672: 0.220139 979: 0.674753 859: 0.287347 541: 0.640039 693: 0.333334 ]"
},
{
"seed": "8cbad4e930b2db21088569117fa10c19c189635cd4a2a960",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 250: 0.601791 98: 0.397101 ]"
},
{
"seed": "84598b5b7c460c0dc5b33ee5e07f1a8943d8cb7e4408d596",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 1100: 0.188858 1226: 0.702469 452: 0.857444 288: 0.626615 638: 0.473099 469: 0.282197 ]"
},
{
"seed": "f7c3cda8fd799d8bb53438e717437c13cd7405c56c72137d",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 11: 0.458737 544: 0.030821 0: 0.379261 222: 0.060329 210: 0.063971 ]"
},
{
"seed": "bf32740c08f065f22be3f8d86079dd851bac365c2291cab6",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 815: 0.544708 1240: 0.714049 470: 0.995779 761: 0.378493 1251: 0.206312 136: 0.143439 876: 0.588002 980: 0.336204 1340: 0.312262 946: 0.499068 1060: 0.600510 648: 0.297045 1047: 0.513620 212: 0.272562 1014: 0.240506 442: 0.659308 214: 0.908140 795: 0.279855 292: 0.444742 1350: 0.052581 863: 0.158584 416: 0.193024 ]"
}
]
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Run the Kotlin obfs4 port against real lyrebird, in both directions.
#
# 🔴 The only check that proves interoperability. Everything else in `:obfs4`
# compares against recorded vectors or against itself, and two identically-wrong
# ends agree perfectly.
#
# ⚠️ A wire-format bug shows up as SILENCE, not an error: obfs4 bridges do not
# answer handshakes they cannot verify. The Kotlin side uses read timeouts and
# reports them as "the bridge refused to answer".
#
# ⚠️ The Go counterparty lives in the flamenet-elligator2 checkout, which also
# holds the Swift port. 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.
set -euo pipefail
cd "$(dirname "$0")/.."
REFERENCE="${FN_OBFS4_REFERENCE:-../flamenet-elligator2/Reference}"
if [[ ! -d "$REFERENCE" ]]; then
echo "Cannot find the Go reference at $REFERENCE."
echo "Clone flamenet-elligator2 beside this repo, or set FN_OBFS4_REFERENCE."
exit 1
fi
WORK="$(mktemp -d)"
PARAMS="$WORK/params.json"
SERVER_LOG="$WORK/server.log"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
# The bridge log is the other half of every diagnosis: it says whether the
# handshake was accepted or refused, which the silent client cannot.
if [[ -s "$SERVER_LOG" ]]; then echo "--- bridge log ---"; cat "$SERVER_LOG"; fi
rm -rf "$WORK"
}
trap cleanup EXIT
echo "building the lyrebird bridge and client..."
(cd "$REFERENCE" && go build -o "$WORK/interopserver" ./interopserver)
(cd "$REFERENCE" && go build -o "$WORK/interopclient" ./interopclient)
"$WORK/interopserver" -params "$PARAMS" -addr 127.0.0.1:0 >/dev/null 2>"$SERVER_LOG" &
SERVER_PID=$!
# ⚠️ Poll for the params file rather than sleeping. A fixed sleep is either
# flaky or slow, and on a loaded machine it is both.
for _ in $(seq 1 100); do
[[ -f "$PARAMS" ]] && break
kill -0 "$SERVER_PID" 2>/dev/null || { echo "the bridge died on startup"; exit 1; }
sleep 0.1
done
[[ -f "$PARAMS" ]] || { echo "the bridge never published its parameters"; exit 1; }
# ⚠️ sed, not python3 or jq: the CI images for this repo cannot be assumed to
# have either, and that assumption has cost red pipelines before.
ADDRESS="$(sed -n 's/.*"address"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PARAMS")"
[[ -n "$ADDRESS" ]] || { echo "could not read the bridge address from $PARAMS"; exit 1; }
echo "bridge up at $ADDRESS"
# 🔴 `--rerun-tasks`, because Gradle's up-to-date check does not know the
# counterparty changed. Without it a second run skips the task entirely and
# prints nothing — the tests do not fail, they simply never happen. Caught by
# the name check below, which is the second time that check has earned its keep
# in ten minutes.
OBFS4_INTEROP_PARAMS="$PARAMS" OBFS4_INTEROP_CLIENT="$WORK/interopclient" \
./gradlew :obfs4:test --tests '*InteropTest*' --rerun-tasks --console=plain 2>&1 \
| tee "$WORK/test.log"
# 🔴 A Gradle filter that matches nothing still exits 0, which would make "no
# interop coverage at all" look exactly like success.
for name in "a handshake and an echo against a real lyrebird bridge" \
"a bridge ignores a handshake it cannot verify" \
"a real lyrebird client accepts the kotlin bridge"; do
# ⚠️ Gradle prints `name() PASSED`, with parentheses. Matching on
# "$name PASSED" silently matches nothing — which this check caught on
# itself, reporting failure while all three tests had passed.
grep -qF "$name() PASSED" "$WORK/test.log" || {
echo
echo "FAILED: '$name' did not pass. A filter that matches nothing exits 0,"
echo "so each test is checked by name rather than trusting the exit code."
exit 1
}
done
echo
echo "ANDROID INTEROP OK: both directions -- a real lyrebird bridge accepted the"
echo "Kotlin client, and a real lyrebird client accepted the Kotlin bridge."
+6
View File
@@ -31,5 +31,11 @@ include(":client")
// be tested without an emulator.
include(":app")
// The obfs4 pluggable transport, for reaching a relay on a network that blocks
// or fingerprints TLS to it. 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 the reference implementation.
include(":obfs4")
// The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`.
include(":interop")