chore: ignore the VSCodium Java extension's bin/ output
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 29s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 10:26:30 +02:00
co-authored by Claude Fable 5
parent 2521d39989
commit 7a94c9a3d7
13 changed files with 3 additions and 972 deletions
@@ -1,166 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package engine composes core-protocol probes into core-measurement documents — the run engine
// the app drives. This file covers the server-facing vertical (control plane + UDP data plane);
// device-tier probes (link snapshot, Shizuku, local discovery) plug in from the Android modules.
package app.echo_lot.engine
import app.echo_lot.measurement.*
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
* an ECHO train (RTT distribution, loss, and NAT-rebinding detection from the server's observed
* source port) as `train.udp_updown`. Everything is real evidence with recomputable metrics, and
* findings are derived deterministically. IDs/timestamps are injected so the engine stays pure
* (no clocks/UUIDs of its own) and unit-testable.
*/
class ServerMeasurement(
private val ids: IdSource,
private val app: AppInfo,
private val device: DeviceInfo,
) {
private val json = Json { encodeDefaults = true; explicitNulls = true }
data class Config(
val controlUrl: String,
val pins: Set<String>,
val credential: String,
val target: String,
val udpHost: String,
val udpPort: Int,
val echoCount: Int = 20,
val echoPaddingBytes: Int = 64,
)
fun run(cfg: Config): MeasurementDocument {
val runId = ids.uuid()
val startWall = ids.nowWall()
val startMono = ids.monoNs()
val control = ControlClient(cfg.controlUrl, cfg.pins)
val profile = control.profile(cfg.credential)
val session = control.createSession(cfg.credential, cfg.target)
val serverSession = ServerSession(
id = "sess-1",
profileName = profile.name,
controlUrl = cfg.controlUrl,
serverVersion = profile.serverVersion,
capabilities = profile.capabilities,
sessionId = session.sessionId,
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
)
val (test, findings) = echoTrain(cfg, control, session, startMono)
control.deleteSession(cfg.credential, session.sessionId)
val summary = Verdicts.derive(listOf(test), findings)
return MeasurementDocument(
run = Run(
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
clock = Clock(monoOriginWall = startWall),
app = app, device = device,
tiers = Tiers(app = true),
),
serverSessions = listOf(serverSession),
tests = listOf(test),
findings = findings,
summary = summary,
)
}
private fun echoTrain(
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val seqs = ArrayList<Int>()
val tTx = ArrayList<Long?>()
val tRx = ArrayList<Long?>()
val sizes = ArrayList<Int>()
val rtts = ArrayList<Double>()
val observedPorts = LinkedHashSet<Int>()
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes)
seqs.add(i)
tTx.add(txMono)
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
if (r != null) {
tRx.add(ids.monoNs() - startMono)
rtts.add(r.rttMs)
r.observation?.observedPort?.let { observedPorts.add(it) }
} else {
tRx.add(null)
}
}
}
val sent = cfg.echoCount
val received = rtts.size
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
val natRebinding = observedPorts.size > 1
val evidence: JsonObject = TrainEvidence(
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
).toEvidence()
val metrics: JsonObject = json.encodeToJsonElement(
EchoMetrics(
sent = sent, received = received, lossPct = round1(lossPct),
rttMsMin = rtts.minOrNull()?.let(::round1),
rttMsAvg = rtts.average().takeIf { received > 0 }?.let(::round1),
rttMsMax = rtts.maxOrNull()?.let(::round1),
observedPorts = observedPorts.toList(),
natRebindingDetected = natRebinding,
)
) as JsonObject
val status = when {
received == 0 -> TestStatus.FAILED
received < sent -> TestStatus.PARTIAL
else -> TestStatus.OK
}
val test = Test(
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = "sess-1", tier = Tier.APP,
startedMonoNs = startMono, endedMonoNs = ids.monoNs(), status = status,
evidence = evidence, metrics = metrics,
)
val findings = ArrayList<Finding>()
if (received == 0) {
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId,
"No UDP echo replies from the server",
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
} else if (lossPct >= 20.0) {
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId,
"High UDP loss to the server (${round1(lossPct)}%)",
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
}
if (natRebinding) {
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId,
"NAT remapped the UDP source port mid-flow",
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
}
return test to findings
}
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
Finding(
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
)
private companion object {
const val Wire_HEADER = 32
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}
@@ -1,38 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.UUID
/**
* Clock/ID source, injected so the engine has no hidden nondeterminism and stays unit-testable.
* The default uses wall + monotonic clocks and random UUIDs; tests supply deterministic ones.
*/
interface IdSource {
fun uuid(): String
fun monoNs(): Long
fun nowWall(): String
}
class SystemIdSource : IdSource {
override fun uuid(): String = UUID.randomUUID().toString()
override fun monoNs(): Long = System.nanoTime()
override fun nowWall(): String = Instant.now().toString()
}
/** Metrics for train.udp_updown; recomputable from the columnar evidence. */
@Serializable
data class EchoMetrics(
val sent: Int,
val received: Int,
@SerialName("loss_pct") val lossPct: Double,
@SerialName("rtt_ms_min") val rttMsMin: Double? = null,
@SerialName("rtt_ms_avg") val rttMsAvg: Double? = null,
@SerialName("rtt_ms_max") val rttMsMax: Double? = null,
@SerialName("observed_ports") val observedPorts: List<Int> = emptyList(),
@SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false,
)
@@ -1,73 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
*
* This is the direction a client cannot measure alone — only the far end can push large or
* numerous packets toward it — so it is also the direction that needs the anti-amplification
* grant, and this test is the proof that the grant path works end to end.
*/
class LiveGrantedTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun downstreamTrainAndBigSend() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
println("capabilities: ${profile.capabilities}")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
ProbeSession(cred, session, host, port).use { ps ->
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
val echo = ps.echo()
println("primed with echo rtt=${echo?.rttMs}")
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
val dtResp = control.action(
cred, session.sessionId,
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
)
println("downtrain accepted: ${dtResp.take(160)}")
val down = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
val seqs = down.map { it.seq }.toSet()
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
"sizes=${down.map { it.sizeBytes }.distinct()}")
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
// --- big_send: which downstream sizes survive? ---
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
val bsResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","sizes_bytes":${sizes}}""",
)
println("big_send accepted: ${bsResp.take(160)}")
val big = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }
val arrived = big.map { it.sizeBytes }.sorted()
println("big_send arrived sizes: $arrived (requested $sizes)")
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
}
control.deleteSession(cred, session.sessionId)
}
}
@@ -1,63 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.*
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Runs the full server-facing engine against a live server and validates the produced
* MeasurementDocument. Self-skips without ECHOLOT_LIVE_* (same contract as core-protocol's live
* test). This is the whole vertical: protocol client → engine → schema document → verdict.
*/
class LiveMeasurementTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun producesValidDocumentFromLiveServer() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveMeasurementTest skipped (no ECHOLOT_LIVE_* env)")
return
}
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val engine = ServerMeasurement(
ids = SystemIdSource(),
app = AppInfo(version = "0.1.0", build = 1, flavor = "test"),
device = DeviceInfo("test", "jvm", 0, "n/a"),
)
val doc = engine.run(
ServerMeasurement.Config(
controlUrl = url, pins = setOf(pin), credential = cred,
target = target, udpHost = host, udpPort = port, echoCount = 20,
)
)
// The document must round-trip and carry the expected structure.
val encoded = Json { encodeDefaults = true }.encodeToString(MeasurementDocument.serializer(), doc)
println("document (${encoded.length} bytes): overall=${doc.summary?.overall}")
assertEquals(1, doc.serverSessions.size)
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
val test = doc.tests.single()
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type)
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
"expected replies from live server, got ${test.status}")
val metrics = Json.parseToJsonElement(test.metrics.toString())
println("metrics: $metrics")
assertTrue(metrics.toString().contains("rtt_ms_avg"))
assertTrue(doc.summary != null)
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
println("summary: ${doc.summary}")
}
}
@@ -1,105 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlinx.serialization.json.Json
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
* java.net.http.HttpClient which needs API 34) with a pin-based SSLSocketFactory and hostname
* verification DISABLED: trust is the SPKI pin, never the certificate name (self-signed servers
* with no SAN are first-class). Blocking; the Android layer wraps calls in coroutines.
*
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
*/
class ControlClient(private val controlUrl: String, pins: Set<String>) {
private val json = Json { ignoreUnknownKeys = true }
private val socketFactory = Pinning.sslContext(pins).socketFactory
private fun open(path: String, method: String, credential: String?): HttpsURLConnection {
val conn = URL(controlUrl.trimEnd('/') + path).openConnection() as HttpsURLConnection
conn.sslSocketFactory = socketFactory
conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name
conn.requestMethod = method
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
return conn
}
private fun body(conn: HttpsURLConnection): String {
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
return stream?.bufferedReader()?.use { it.readText() } ?: ""
}
private fun writeJson(conn: HttpsURLConnection, payload: String) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.outputStream.use { it.write(payload.toByteArray()) }
}
// Minimal JSON string literal (the only bodies we send are one short field).
private fun jstr(s: String): String {
val sb = StringBuilder("\"")
for (c in s) when (c) {
'"' -> sb.append("\\\"")
'\\' -> sb.append("\\\\")
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
else -> sb.append(c)
}
return sb.append('"').toString()
}
/** Redeem a single-use enrollment token for a device credential (§2.1). */
fun enroll(token: String, name: String? = null): EnrollResponse {
val conn = open("/v1/enroll", "POST", null)
conn.setRequestProperty("Authorization", "Bearer $token")
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(EnrollResponse.serializer(), body(conn))
}
fun profile(credential: String): Profile {
val conn = open("/v1/profile", "GET", credential)
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(Profile.serializer(), body(conn))
}
fun createSession(credential: String, target: String): SessionResponse {
val conn = open("/v1/sessions", "POST", credential)
writeJson(conn, """{"target":${jstr(target)}}""")
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(SessionResponse.serializer(), body(conn))
}
/**
* Requests a §5 action. The server creates an asymmetric grant for the granted ones
* (downtrain / big_send) and starts sending toward the session's observed data-plane source,
* so the caller must already have sent at least one ECHO. Returns the raw JSON reply.
*/
fun action(credential: String, sessionId: String, bodyJson: String): String {
val conn = open("/v1/sessions/$sessionId/actions", "POST", credential)
writeJson(conn, bodyJson)
val body = body(conn)
check(conn.responseCode in 200..299) { "action failed: ${conn.responseCode} $body" }
return body
}
fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
return body(conn)
}
fun deleteSession(credential: String, sessionId: String) {
open("/v1/sessions/$sessionId", "DELETE", credential).responseCode
}
}
@@ -1,53 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* The protocol crypto primitives, matching the server exactly (probe-protocol.md §2.4/§3.1):
* HMAC-SHA256 for the data-plane gate, and HKDF-SHA256 for the session key
* `HKDF(ikm = device_credential, salt = key_salt, info = "echolot-v1/" + session_id)`.
* JDK-only (javax.crypto) — no third-party crypto.
*/
object Crypto {
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256"))
doFinal(data)
}
/** First 4 bytes of HMAC-SHA256 — the wire anti-abuse gate (spec §3.1). */
fun hmac32(key: ByteArray, data: ByteArray): ByteArray = hmacSha256(key, data).copyOf(4)
/**
* HKDF-SHA256 (RFC 5869) extract-then-expand. The JDK exposes no HKDF, so it is built from
* HMAC — small and standard.
*/
fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
val prk = hmacSha256(if (salt.isEmpty()) ByteArray(32) else salt, ikm) // extract
val out = ByteArray(length)
var t = ByteArray(0)
var pos = 0
var counter = 1
while (pos < length) {
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(prk, "HmacSHA256")) }
mac.update(t)
mac.update(info)
mac.update(counter.toByte())
t = mac.doFinal()
val n = minOf(t.size, length - pos)
t.copyInto(out, pos, 0, n)
pos += n
counter++
}
return out
}
/** Derives the 32-byte session key for a session (spec §2.4). */
fun sessionKey(credential: String, keySalt: ByteArray, sessionId: String): ByteArray =
hkdfSha256(credential.toByteArray(), keySalt, "echolot-v1/$sessionId".toByteArray(), 32)
}
@@ -1,64 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Control-plane JSON shapes (probe-protocol.md §2). Only fields the client uses are modeled;
* unknown fields are ignored by the lenient Json in [ControlClient]. */
@Serializable
data class EnrollResponse(
@SerialName("device_id") val deviceId: String,
val credential: String,
)
@Serializable
data class Target(
val id: String,
val ip4: String? = null,
val ip6: String? = null,
@SerialName("udp_port") val udpPort: Int = 0,
@SerialName("tcp_port") val tcpPort: Int = 0,
@SerialName("stun_port") val stunPort: Int = 0,
)
@Serializable
data class SelfTest(
@SerialName("mtu_ok") val mtuOk: Boolean? = null,
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null,
)
@Serializable
data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0,
val name: String = "",
@SerialName("server_version") val serverVersion: String = "",
val capabilities: List<String> = emptyList(),
val targets: List<Target> = emptyList(),
@SerialName("canary_zone") val canaryZone: String = "",
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
val pins: List<String> = emptyList(),
) {
fun supports(capability: String) = capability in capabilities
}
@Serializable
data class SessionResponse(
@SerialName("session_id") val sessionId: String,
@SerialName("key_salt") val keySalt: String, // base64
val epoch: String,
@SerialName("expires_s") val expiresS: Int,
)
/** Observations bundle (§6). Kept as raw JSON where the shape is still evolving server-side. */
@Serializable
data class Observations(
val udp: JsonElement? = null,
val tcp: JsonElement? = null,
@SerialName("connect_back") val connectBack: JsonElement? = null,
@SerialName("dns_canary") val dnsCanary: JsonElement? = null,
)
@@ -1,39 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.security.MessageDigest
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
/**
* SPKI-pinned trust (probe-protocol.md §1): the client trusts the server ONLY against the
* `pin-sha256` from enrollment — CA validation is not required and self-signed is first-class.
* The pin is base64(SHA-256(SubjectPublicKeyInfo)), RFC 7469.
*/
object Pinning {
fun spkiPin(cert: X509Certificate): String {
val spki = cert.publicKey.encoded // DER SubjectPublicKeyInfo
val digest = MessageDigest.getInstance("SHA-256").digest(spki)
return java.util.Base64.getEncoder().encodeToString(digest)
}
/** An SSLContext that accepts a chain iff its leaf SPKI matches one of the expected pins. */
fun sslContext(expectedPins: Set<String>): SSLContext {
val tm = object : X509TrustManager {
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
val leaf = chain.firstOrNull() ?: throw java.security.cert.CertificateException("empty chain")
val pin = spkiPin(leaf)
if (pin !in expectedPins) {
throw java.security.cert.CertificateException("SPKI pin mismatch: got $pin")
}
}
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
return SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
}
}
@@ -1,111 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.util.Base64
/**
* A data-plane session (probe-protocol.md §3): derives the session key, then sends signed ELT1
* packets to the server's UDP endpoint and reads back verified responses. One session ↔ one
* server target. Blocking; the caller owns threading.
*/
class ProbeSession(
private val credential: String,
private val session: SessionResponse,
private val serverHost: String,
private val serverUdpPort: Int,
) : AutoCloseable {
private val key: ByteArray =
Crypto.sessionKey(credential, Base64.getDecoder().decode(session.keySalt), session.sessionId)
private val prefix: ByteArray = Wire.wirePrefix(session.sessionId)
private val epochNanos = System.nanoTime()
private val socket = DatagramSocket().apply { soTimeout = 3000 }
private val server = InetSocketAddress(serverHost, serverUdpPort)
private var seq = 0
private fun nowNs() = System.nanoTime() - epochNanos
/**
* One ECHO round trip. Returns RTT in ms and the server's observation, or null on loss.
*
* The response is capped at the request size (§3.4 anti-amplification) and the observation
* block is 40 bytes, so the request must be at least header+40 = 72 bytes for the full
* observation to fit — hence the ≥40 default padding. Smaller requests still measure RTT.
*/
fun echo(paddingBytes: Int = 40): EchoResult? {
val t0 = System.nanoTime()
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, ++seq, nowNs(), key, ByteArray(paddingBytes))
socket.send(DatagramPacket(pkt, pkt.size, server))
val resp = receive(Wire.TYPE_ECHO_RESP) ?: return null
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
return EchoResult(rttMs, Observation.parse(resp.payload))
}
/** One MTU probe of [totalSize] bytes (DF is set by the OS on the socket where supported).
* Returns the size the server acknowledged receiving, or null if the probe was lost. */
fun mtuProbe(totalSize: Int): Int? {
val payloadLen = (totalSize - Wire.HEADER_SIZE).coerceAtLeast(0)
val pkt = Wire.build(Wire.TYPE_MTU_PROBE, prefix, ++seq, nowNs(), key, ByteArray(payloadLen))
socket.send(DatagramPacket(pkt, pkt.size, server))
val resp = receive(Wire.TYPE_MTU_ACK) ?: return null
if (resp.payload.size < 4) return null
return ((resp.payload[0].toInt() and 0xFF) shl 24) or
((resp.payload[1].toInt() and 0xFF) shl 16) or
((resp.payload[2].toInt() and 0xFF) shl 8) or
(resp.payload[3].toInt() and 0xFF)
}
/**
* Collects packets the SERVER sends under a grant (downtrain / big_send) for [windowMs].
* These arrive unsolicited after a control-plane action, so this just drains the socket and
* keeps every HMAC-verified packet — anything that fails verification is not ours and is
* silently ignored (an injected packet must not be able to fake a measurement).
*/
fun collectGranted(windowMs: Long): List<Received> {
val out = ArrayList<Received>()
val deadline = System.nanoTime() + windowMs * 1_000_000
val buf = ByteArray(9200)
val prevTimeout = socket.soTimeout
try {
while (System.nanoTime() < deadline) {
val remainMs = ((deadline - System.nanoTime()) / 1_000_000).toInt()
if (remainMs <= 0) break
socket.soTimeout = remainMs.coerceAtMost(2000)
val dp = DatagramPacket(buf, buf.size)
try {
socket.receive(dp)
} catch (e: java.net.SocketTimeoutException) {
continue
}
val pkt = Wire.parseVerified(buf, dp.length, key) ?: continue
out.add(Received(pkt.type, pkt.seq, dp.length, (System.nanoTime() - epochNanos)))
}
} finally {
socket.soTimeout = prevTimeout
}
return out
}
/** One packet received from the server, with the wire size actually delivered. */
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
private fun receive(wantType: Int): Wire.Packet? {
val buf = ByteArray(2048)
return try {
val dp = DatagramPacket(buf, buf.size)
socket.receive(dp)
Wire.parseVerified(buf, dp.length, key)?.takeIf { it.type == wantType }
} catch (e: java.net.SocketTimeoutException) {
null
}
}
override fun close() = socket.close()
data class EchoResult(val rttMs: Double, val observation: Observation?)
}
@@ -1,118 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* The binary UDP probe protocol wire format (probe-protocol.md §3.1): a fixed 32-byte header
* plus payload, HMAC-gated. Mirrors the Go server's dataplane package byte-for-byte.
*
* ```
* 0 4 magic "ELT1" 8 8 session_prefix (first 8 bytes of session id)
* 4 1 type 16 4 seq
* 5 1 flags 20 8 t_ns (sender clock, ns since session epoch)
* 6 2 payload_len 28 4 hmac32(session_key, header[0..28] || payload)
* ```
*/
object Wire {
const val HEADER_SIZE = 32
val MAGIC = byteArrayOf('E'.code.toByte(), 'L'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte())
const val TYPE_ECHO_REQ: Int = 0x01
const val TYPE_ECHO_RESP: Int = 0x02
const val TYPE_TIMESYNC_REQ: Int = 0x07
const val TYPE_TIMESYNC_RSP: Int = 0x08
const val TYPE_MTU_PROBE: Int = 0x09
const val TYPE_MTU_ACK: Int = 0x0A
const val TYPE_DELAYED_ECHO: Int = 0x0B
/** Server->client under an asymmetric grant (spec §3.4/§5). */
const val TYPE_DOWNTRAIN_DATA: Int = 0x06
const val TYPE_BIG_SEND: Int = 0x0C
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
fun wirePrefix(sessionId: String): ByteArray {
require(sessionId.length >= 16) { "session id too short" }
val p = ByteArray(8)
for (i in 0 until 8) {
p[i] = ((hex(sessionId[i * 2]) shl 4) or hex(sessionId[i * 2 + 1])).toByte()
}
return p
}
private fun hex(c: Char): Int = when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> 0
}
/** Builds a signed packet ready to send. */
fun build(
type: Int, sessionPrefix: ByteArray, seq: Int, tNs: Long, key: ByteArray,
payload: ByteArray = ByteArray(0),
): ByteArray {
val buf = ByteBuffer.allocate(HEADER_SIZE + payload.size).order(ByteOrder.BIG_ENDIAN)
buf.put(MAGIC)
buf.put(type.toByte())
buf.put(0) // flags
buf.putShort(payload.size.toShort())
buf.put(sessionPrefix, 0, 8)
buf.putInt(seq)
buf.putLong(tNs)
buf.position(28) // leave hmac slot; fill after
buf.putInt(0)
buf.put(payload)
val bytes = buf.array()
// HMAC over header[0..28] || payload (the hmac slot itself excluded).
val mac = Crypto.hmacSha256(key, concat(bytes, 0, 28, bytes, HEADER_SIZE, payload.size))
mac.copyInto(bytes, 28, 0, 4)
return bytes
}
/** A parsed, HMAC-verified inbound packet. */
data class Packet(val type: Int, val seq: Int, val tNs: Long, val payload: ByteArray)
/** Parses and verifies an inbound datagram; null if malformed or the HMAC fails. */
fun parseVerified(data: ByteArray, len: Int, key: ByteArray): Packet? {
if (len < HEADER_SIZE) return null
for (i in MAGIC.indices) if (data[i] != MAGIC[i]) return null
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
val type = bb.get(4).toInt() and 0xFF
val payloadLen = bb.getShort(6).toInt() and 0xFFFF
if (HEADER_SIZE + payloadLen > len) return null
val expect = Crypto.hmacSha256(key, concat(data, 0, 28, data, HEADER_SIZE, payloadLen))
for (i in 0 until 4) if (expect[i] != data[28 + i]) return null
val seq = bb.getInt(16)
val tNs = bb.getLong(20)
val payload = data.copyOfRange(HEADER_SIZE, HEADER_SIZE + payloadLen)
return Packet(type, seq, tNs, payload)
}
private fun concat(a: ByteArray, aOff: Int, aLen: Int, b: ByteArray, bOff: Int, bLen: Int): ByteArray {
val out = ByteArray(aLen + bLen)
a.copyInto(out, 0, aOff, aOff + aLen)
b.copyInto(out, aLen, bOff, bOff + bLen)
return out
}
}
/** Server observation block appended to ECHO_RESP (spec §3.3), fixed 40 bytes. */
data class Observation(
val tRxNs: Long, val tTxNs: Long, val observedPort: Int, val receivedSize: Int,
) {
companion object {
fun parse(payload: ByteArray): Observation? {
if (payload.size < 40) return null
val bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN)
return Observation(
tRxNs = bb.getLong(0),
tTxNs = bb.getLong(8),
observedPort = bb.getShort(32).toInt() and 0xFFFF,
receivedSize = bb.getInt(36),
)
}
}
}
@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class CryptoWireTest {
@Test
fun hkdfMatchesRfc5869Vector() {
// RFC 5869 Appendix A.1 (SHA-256).
val ikm = ByteArray(22) { 0x0b }
val salt = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
val info = byteArrayOf(
0xf0.toByte(), 0xf1.toByte(), 0xf2.toByte(), 0xf3.toByte(), 0xf4.toByte(),
0xf5.toByte(), 0xf6.toByte(), 0xf7.toByte(), 0xf8.toByte(), 0xf9.toByte(),
)
val okm = Crypto.hkdfSha256(ikm, salt, info, 42)
val expect = "3cb25f25faacd57a90434f64d0362f2a" +
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf" +
"34007208d5b887185865"
assertEquals(expect, okm.joinToString("") { "%02x".format(it) })
}
@Test
fun wirePrefixDecodesHex() {
val prefix = Wire.wirePrefix("805a43f8395ae08ace7a14803766cb11")
assertEquals("805a43f8395ae08a", prefix.joinToString("") { "%02x".format(it) })
}
@Test
fun buildThenParseRoundTripsAndVerifies() {
val key = ByteArray(32) { it.toByte() }
val prefix = ByteArray(8) { (it + 1).toByte() }
val payload = "hello-echolot".toByteArray()
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, 7, 123_456L, key, payload)
assertEquals(Wire.HEADER_SIZE + payload.size, pkt.size)
val parsed = Wire.parseVerified(pkt, pkt.size, key)
assertNotNull(parsed)
assertEquals(Wire.TYPE_ECHO_REQ, parsed.type)
assertEquals(7, parsed.seq)
assertEquals(123_456L, parsed.tNs)
assertEquals("hello-echolot", String(parsed.payload))
}
@Test
fun tamperedHmacIsRejected() {
val key = ByteArray(32) { it.toByte() }
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, key, ByteArray(4))
pkt[pkt.size - 1] = (pkt[pkt.size - 1].toInt() xor 0xFF).toByte() // flip a payload byte
assertNull(Wire.parseVerified(pkt, pkt.size, key))
}
@Test
fun wrongKeyIsRejected() {
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, ByteArray(32) { 1 }, ByteArray(0))
assertNull(Wire.parseVerified(pkt, pkt.size, ByteArray(32) { 2 }))
}
@Test
fun observationParses() {
// 40-byte block: t_rx, t_tx, 16-byte addr, port, ttl/dscp, size.
val b = ByteArray(40)
b[33] = 0x1F // port low byte = 8191... set port bytes 32..33
b[32] = 0x00
val obs = Observation.parse(b)
assertNotNull(obs)
assertTrue(obs.observedPort in 0..65535)
}
}
@@ -1,66 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* End-to-end test of the Kotlin client against a REAL running server. It self-skips unless the
* environment provides a live target, so it never breaks CI (no network / no server):
*
* ECHOLOT_LIVE_URL = https://fmr-1.echo-lot.app:8443
* ECHOLOT_LIVE_PIN = <base64 pin-sha256>
* ECHOLOT_LIVE_CRED = <device credential from an enrollment>
* ECHOLOT_LIVE_UDP = fmr-1.echo-lot.app:8442
* ECHOLOT_LIVE_TARGET = fmr (profile target id)
*
* The harness (test-fmr.sh) mints a token over SSH, enrolls via the public control plane, and
* exports these — proving the client talks to the deployed server over the wire.
*/
class LiveServerTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun fullFlowAgainstLiveServer() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveServerTest skipped (no ECHOLOT_LIVE_* env)")
return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
println("profile: name=${profile.name} v=${profile.serverVersion} caps=${profile.capabilities}")
assertTrue(profile.supports("udp-probe"), "server must offer udp-probe")
val session = control.createSession(cred, target)
println("session: ${session.sessionId} expires=${session.expiresS}s")
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
ProbeSession(cred, session, host, port).use { ps ->
// ECHO: verified response + observation with our observed port.
val echo = ps.echo(paddingBytes = 64) // ≥40 so the observation block fits (§3.4)
assertNotNull(echo, "no verified ECHO_RESP from live server")
println("echo rtt=${"%.1f".format(echo.rttMs)}ms observedPort=${echo.observation?.observedPort} size=${echo.observation?.receivedSize}")
assertNotNull(echo.observation, "ECHO_RESP missing observation block")
// MTU probe: server acks the size it received.
val acked = ps.mtuProbe(1400)
assertNotNull(acked, "no MTU_ACK from live server")
println("mtu probe 1400 -> server received $acked bytes")
assertTrue(acked!! in 1300..1500, "acked size implausible: $acked")
}
val obs = control.observations(cred, session.sessionId)
println("observations bytes: ${obs.length}")
assertTrue(obs.contains("packets_seen"), "observations should report packets_seen")
control.deleteSession(cred, session.sessionId)
}
}