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