obfs4 for Android, part 5: handshake messages, packets, and the connection

The protocol is now complete in Kotlin, short of a socket. 58 tests.

Handshake messages are checked against RECORDINGS, not computed values:
the vectors are real lyrebird-to-lyrebird handshakes captured off a pipe,
because the message carries random padding chosen inside lyrebird and a
MAC over the current epoch hour. The padding turns out to be recoverable
-- it sits between a fixed-length prefix and the mark -- so rebuilding
from the recovered representative, padding and hour must reproduce the
recording byte for byte. That makes these a test of generation, not only
of parsing, and both directions pass.

The epoch hour is a parameter, never read from the clock inside the
library, because a MAC with a hidden clock dependency cannot be tested
against a recording.

The asymmetry that catches people is preserved: the server searches only
the TAIL for the mark, because a client cannot send anything valid past
its MAC; the client must SUBSTRING-search, because a real server sends
framed data immediately after its handshake. The symmetric reading fails
against every real bridge.

GoRandom reproduces Go's math/rand quirks exactly -- float64 dividing an
int63 by 2^63 rather than the cleaner form Go rejected, and perm's
useless first swap -- so the packet-length distribution derived from a
seed matches lyrebird's. That buys indistinguishability, NOT
interoperability: padding is padding, and any distribution talks to a
real bridge. What a different one costs is looking like other obfs4
traffic, which is the entire point.

The client handshake returns leftover BYTES rather than an offset,
carrying forward the fix the Swift port needed: an offset indexes a
buffer accumulated across all calls, which a caller feeding one socket
read at a time does not have, and it only misfires when the server
handshake spans two reads -- routine over a network, almost never over
loopback.

HONEST GAP: the connection layer here is self-consistency-tested only.
Everything below it is checked against lyrebird's bytes, but two
identically-wrong ends agree perfectly. The Swift port has a live interop
test against a real lyrebird bridge; this one does not yet, and that is
the next thing that matters -- more than the OkHttp seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-06 13:26:53 -04:00
co-authored by Claude Opus 5
parent 799886f35c
commit ac5802a839
7 changed files with 1835 additions and 0 deletions
@@ -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,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,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,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)
}
}
File diff suppressed because one or more lines are too long
+567
View File
@@ -0,0 +1,567 @@
{
"note": "Go math/rand semantics over the obfs4 SipHash DRBG. Each sequence starts from a fresh generator on the same seed.",
"rand": [
{
"seed": "05f986cf515f1786b71a6c420bb3fcbcd1bcd5c366eea72c",
"int63": [
"180037798733823549",
"1023797179738791847",
"1761865624249628279",
"8192978223349593882",
"1349136184594006180",
"8591953938801926553",
"8698444496158717931",
"3413277081198754956",
"1207130668310984232",
"6726874423714744141",
"6546122928516813399",
"2707351428806462127"
],
"intn_100": [
16,
56,
7,
93,
46,
65,
30,
99,
10,
1,
14,
75
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.01951973725167195,
0.11100031264573305,
0.19102185374389763,
0.8882844788882058,
0.1462736382315626,
0.9315415126344435,
0.9430872419979861,
0.37006824267306715,
0.1308773693056648,
0.7293291864228701,
0.7097320700455102,
0.29353163007937005
],
"perm_10": [
6,
9,
1,
7,
3,
4,
8,
0,
2,
5
]
},
{
"seed": "8c5cf36e1e8fa723b348d6b445fb506eefbd3b9a0ad37dce",
"int63": [
"6480070349799675829",
"2016991850649746136",
"4672302796067101900",
"357010723544883299",
"8249026412712778780",
"8117261994706136172",
"8524685317237317809",
"9067896476093263171",
"7204963904149063477",
"5918189176239140632",
"8296980046950967925",
"4879373786393850965"
],
"intn_100": [
50,
10,
67,
36,
29,
34,
69,
73,
83,
95,
4,
39
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.7025706351111711,
0.2186826946360013,
0.5065720842006048,
0.03870718020679843,
0.8943612357553502,
0.8800753089294412,
0.9242482340703981,
0.9831433059253966,
0.7811637517558057,
0.6416513562058674,
0.8995603791973122,
0.5290227659577035
],
"perm_10": [
3,
2,
5,
1,
8,
9,
4,
0,
6,
7
]
},
{
"seed": "4cf9469006755e56a425a5fb84c2ffaab5f9a61b9ad1a34d",
"int63": [
"5542255436954797446",
"6078982071921011436",
"5361671747467629728",
"3075746401306639186",
"8472145921656303700",
"2079445698696703736",
"7921987114482477959",
"3993010511109309647",
"6498441858198684587",
"6246705345307048144",
"5806817419838080090",
"359784335928015979"
],
"intn_100": [
0,
8,
84,
14,
88,
80,
58,
7,
0,
26,
37,
18
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.600892538521599,
0.6590845568877194,
0.5813136156758555,
0.33347309303111305,
0.9185519013873971,
0.2254539544092604,
0.8589035639924075,
0.4329230670902168,
0.7045624780429752,
0.6772691506258715,
0.6295764061815118,
0.03900789586394095
],
"perm_10": [
1,
0,
5,
4,
2,
8,
9,
7,
3,
6
]
},
{
"seed": "c16432812f8641222e22fb598c3cea1f49f421f0bd9b6a16",
"int63": [
"7137948479543349739",
"1068628927803704967",
"1857275370826524684",
"7532112879535655864",
"9005458455971642079",
"6903441880655607317",
"1643616422352972231",
"5572956403993938525",
"2870650832098737127",
"2674433592483187385",
"5268406440076844205",
"5034870401071075879"
],
"intn_100": [
38,
61,
20,
89,
88,
16,
69,
26,
80,
94,
62,
16
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.7738979248610502,
0.11586098051056322,
0.2013661991953939,
0.8166333147398607,
0.9763737622192411,
0.7484726684634226,
0.1782012495847945,
0.6042211440377233,
0.3112365868608772,
0.2899626711138484,
0.5712017707867937,
0.5458817427024224
],
"perm_10": [
2,
3,
8,
4,
9,
1,
0,
6,
7,
5
]
},
{
"seed": "1272fa54b80b693a1526e76aad73d47ca1000486ffc8e2ea",
"int63": [
"2266299523755372919",
"4962372238692395965",
"5557210371389255082",
"5217975692527489687",
"3870789316561968530",
"473880929066103316",
"6692075858658946530",
"1539140837069603787",
"6923397241901753123",
"2001026984266605650",
"6558174712351968716",
"1767308011536092577"
],
"intn_100": [
76,
22,
67,
39,
68,
1,
29,
51,
36,
0,
39,
61
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.24571268671584393,
0.5380214761872052,
0.6025139557619207,
0.5657340581814859,
0.41967181862501673,
0.05137827327929184,
0.7255563184395827,
0.16687398393120223,
0.750636233065003,
0.2169517803543971,
0.7110387270671502,
0.19161191855584686
],
"perm_10": [
9,
5,
1,
6,
3,
0,
4,
8,
7,
2
]
},
{
"seed": "9d9eaeac852bb17e0f57256adfc4e23c97158a624e6a86a1",
"int63": [
"8991812859880464258",
"5864497437943987359",
"6553513902164822514",
"7605031751507615879",
"2862314111135425419",
"1323727665288322773",
"2767553488843912858",
"3086215113452383763",
"8999580915650212647",
"4308430354182295929",
"6922747770517395456",
"6692310895210159581"
],
"intn_100": [
75,
13,
60,
37,
36,
64,
60,
51,
17,
12,
19,
53
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.9748943037265496,
0.6358300862754546,
0.7105334010141056,
0.8245391946805798,
0.3103327177628944,
0.14351884104847643,
0.30005875050744074,
0.3346081130762672,
0.9757365180207046,
0.46712095500069367,
0.7505658172364142,
0.7255818011535266
],
"perm_10": [
5,
6,
9,
7,
3,
2,
4,
1,
8,
0
]
},
{
"seed": "07a52011ee7b7304d72a28cab672806d997f5b99588b0296",
"int63": [
"3772413026592079685",
"52919815567231157",
"3968348385632365615",
"8922280692955313603",
"3165629534368815027",
"1053003750036652473",
"7276818834677432211",
"2207120645876153924",
"6837452092266243048",
"1309745529840857675",
"6794975952186715247",
"4151696207094747609"
],
"intn_100": [
53,
54,
8,
56,
62,
40,
13,
20,
71,
94,
24,
0
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.409005839894375,
0.005737577900552424,
0.4302491940881955,
0.9673556110827622,
0.3432182418446945,
0.11416689534251216,
0.7889542789340709,
0.23929649991965357,
0.7413180412700618,
0.1420028948856636,
0.7367127689347596,
0.4501278047231954
],
"perm_10": [
7,
2,
5,
1,
9,
8,
3,
6,
4,
0
]
},
{
"seed": "12f82fcdefa04a21e7f23985b765d0d17ec80f9f7ff3909c",
"int63": [
"4884570228851776789",
"3021910336018798234",
"2485106168582921328",
"1400166114474197866",
"3736327909890935317",
"2255434195930023737",
"8487819480951069098",
"8944001114665822912",
"6338008241217828283",
"4308836362553729409",
"7018135233767328510",
"5390649060585650388"
],
"intn_100": [
30,
28,
68,
77,
32,
95,
72,
36,
38,
43,
43,
90
],
"intn_1": [
0,
0,
0,
0
],
"float64": [
0.5295861653779114,
0.32763617513679816,
0.26943575068347314,
0.15180631431535127,
0.4050934837021976,
0.24453466551254285,
0.9202512320911935,
0.9697105439233459,
0.68716823043594,
0.4671649745165292,
0.7609077467247601,
0.5844553422593906
],
"perm_10": [
7,
3,
4,
9,
8,
6,
5,
1,
2,
0
]
}
],
"dist": [
{
"seed": "1e87266907e9cf536ed49b8b1ff8c603df48465d98d50494",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 631: 0.921909 1330: 0.050197 323: 0.022911 ]"
},
{
"seed": "4d5d5e8b054319791eb2d17453d380b706ef9cff36300f07",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 1262: 0.717798 1120: 0.429891 282: 0.122173 1361: 0.735515 509: 0.131023 702: 0.497105 1152: 0.473666 1112: 0.708108 576: 0.689546 907: 0.487596 607: 0.644117 335: 0.643810 758: 0.048144 677: 0.145764 119: 0.722769 596: 0.530116 1385: 0.583168 1130: 0.525135 148: 0.852064 1110: 0.630802 1372: 0.376404 632: 0.715470 574: 0.262976 987: 0.922780 39: 0.673047 1004: 0.820344 675: 0.132190 227: 0.397274 1409: 0.060960 240: 0.196218 1140: 0.903684 1175: 0.848127 188: 0.799259 271: 0.716531 928: 0.841572 871: 0.557789 408: 0.684364 342: 0.387401 589: 0.205922 815: 0.994212 991: 0.625443 360: 0.618060 388: 0.470400 1200: 0.313900 383: 0.193660 1027: 0.987201 378: 0.370931 1022: 0.984187 346: 0.360472 1050: 0.867503 ]"
},
{
"seed": "fec6b77732e0e7fecfa2498a4c8639272351f45c830cd02f",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 1029: 0.923662 989: 0.065330 ]"
},
{
"seed": "2eee8286f5f9d4229ab261181098c58910c65bc110639165",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 170: 0.494179 706: 0.986113 1224: 0.275960 1378: 0.603452 404: 0.935409 463: 0.976579 987: 0.578662 1315: 0.025583 267: 0.671236 1181: 0.686961 297: 0.390292 411: 0.623637 11: 0.488163 32: 0.784112 1061: 0.699220 514: 0.989684 647: 0.474383 68: 0.162215 9: 0.055838 1014: 0.255816 749: 0.717501 277: 0.257325 303: 0.685037 517: 0.791158 1287: 0.712921 358: 0.647147 804: 0.163843 120: 0.321571 933: 0.323333 1191: 0.450765 590: 0.426373 993: 0.390693 474: 0.727644 551: 0.266102 76: 0.644561 256: 0.931082 415: 0.836679 535: 0.202349 988: 0.693359 877: 0.217575 683: 0.991575 1353: 0.740492 166: 0.614490 236: 0.944088 713: 0.712298 1350: 0.599826 776: 0.800988 582: 0.522193 947: 0.394854 781: 0.920032 132: 0.155250 157: 0.644944 1282: 0.409974 838: 0.324449 672: 0.220139 979: 0.674753 859: 0.287347 541: 0.640039 693: 0.333334 ]"
},
{
"seed": "8cbad4e930b2db21088569117fa10c19c189635cd4a2a960",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 250: 0.601791 98: 0.397101 ]"
},
{
"seed": "84598b5b7c460c0dc5b33ee5e07f1a8943d8cb7e4408d596",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 1100: 0.188858 1226: 0.702469 452: 0.857444 288: 0.626615 638: 0.473099 469: 0.282197 ]"
},
{
"seed": "f7c3cda8fd799d8bb53438e717437c13cd7405c56c72137d",
"min": 0,
"max": 1448,
"biased": true,
"string": "[ 11: 0.458737 544: 0.030821 0: 0.379261 222: 0.060329 210: 0.063971 ]"
},
{
"seed": "bf32740c08f065f22be3f8d86079dd851bac365c2291cab6",
"min": 0,
"max": 1448,
"biased": false,
"string": "[ 815: 0.544708 1240: 0.714049 470: 0.995779 761: 0.378493 1251: 0.206312 136: 0.143439 876: 0.588002 980: 0.336204 1340: 0.312262 946: 0.499068 1060: 0.600510 648: 0.297045 1047: 0.513620 212: 0.272562 1014: 0.240506 442: 0.659308 214: 0.908140 795: 0.279855 292: 0.444742 1350: 0.052581 863: 0.158584 416: 0.193024 ]"
}
]
}