Merge branch 'feat/obfs4-kotlin-elligator2' into 'main'
obfs4 for Android, part 1: Elligator2 See merge request paulhitt/flamenet-messenger-android!77
This commit was merged in pull request #104.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 ]"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+91
@@ -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."
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user