diff --git a/.gitignore b/.gitignore index b67a25b..41b2caf 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ keystore.properties # wrangler build/dev artifacts web/.wrangler/ + +# Eclipse/JDT output from the VSCodium Java extension — not a build artifact we own +echolot-app/*/bin/ diff --git a/echolot-app/core-engine/bin/main/app/echo_lot/engine/ServerMeasurement.kt b/echolot-app/core-engine/bin/main/app/echo_lot/engine/ServerMeasurement.kt deleted file mode 100644 index bc1ba68..0000000 --- a/echolot-app/core-engine/bin/main/app/echo_lot/engine/ServerMeasurement.kt +++ /dev/null @@ -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, - 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> { - val testId = ids.uuid() - val seqs = ArrayList() - val tTx = ArrayList() - val tRx = ArrayList() - val sizes = ArrayList() - val rtts = ArrayList() - val observedPorts = LinkedHashSet() - - 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() - 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 - } -} diff --git a/echolot-app/core-engine/bin/main/app/echo_lot/engine/Support.kt b/echolot-app/core-engine/bin/main/app/echo_lot/engine/Support.kt deleted file mode 100644 index 2fc50cf..0000000 --- a/echolot-app/core-engine/bin/main/app/echo_lot/engine/Support.kt +++ /dev/null @@ -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 = emptyList(), - @SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false, -) diff --git a/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveGrantedTest.kt b/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveGrantedTest.kt deleted file mode 100644 index f8327be..0000000 --- a/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveGrantedTest.kt +++ /dev/null @@ -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) - } -} diff --git a/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveMeasurementTest.kt b/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveMeasurementTest.kt deleted file mode 100644 index 1619b38..0000000 --- a/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveMeasurementTest.kt +++ /dev/null @@ -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}") - } -} diff --git a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ControlClient.kt deleted file mode 100644 index 2ff261d..0000000 --- a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ControlClient.kt +++ /dev/null @@ -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) { - - 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 - } -} diff --git a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Crypto.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Crypto.kt deleted file mode 100644 index 051a3eb..0000000 --- a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Crypto.kt +++ /dev/null @@ -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) -} diff --git a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Model.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Model.kt deleted file mode 100644 index 84b5b16..0000000 --- a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Model.kt +++ /dev/null @@ -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 = emptyList(), - val targets: List = emptyList(), - @SerialName("canary_zone") val canaryZone: String = "", - @SerialName("server_selftest") val serverSelftest: SelfTest? = null, - val pins: List = 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, -) diff --git a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Pinning.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Pinning.kt deleted file mode 100644 index f25d689..0000000 --- a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Pinning.kt +++ /dev/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): SSLContext { - val tm = object : X509TrustManager { - override fun checkServerTrusted(chain: Array, 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, authType: String) = Unit - override fun getAcceptedIssuers(): Array = emptyArray() - } - return SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) } - } -} diff --git a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ProbeSession.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ProbeSession.kt deleted file mode 100644 index 6266726..0000000 --- a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ProbeSession.kt +++ /dev/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 { - val out = ArrayList() - 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?) -} diff --git a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Wire.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Wire.kt deleted file mode 100644 index 2462f33..0000000 --- a/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Wire.kt +++ /dev/null @@ -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), - ) - } - } -} diff --git a/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/CryptoWireTest.kt b/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/CryptoWireTest.kt deleted file mode 100644 index ccc7b71..0000000 --- a/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/CryptoWireTest.kt +++ /dev/null @@ -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) - } -} diff --git a/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/LiveServerTest.kt b/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/LiveServerTest.kt deleted file mode 100644 index 5f63958..0000000 --- a/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/LiveServerTest.kt +++ /dev/null @@ -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 = - * ECHOLOT_LIVE_CRED = - * 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) - } -}