diff --git a/docs/build-status.md b/docs/build-status.md index 86b0c83..042f676 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -299,3 +299,18 @@ Spec §4 (TCP/TLS/HTTP/STUN) is now fully implemented. Server capabilities: udp- delayed-echo, connect-back, http-echo, tcp-echo, tls-echo, stun-5780, canary-dns. Remaining spec: §5 heavy actions (downtrain/big_send/frag_send/throughput) + the TRAIN_REPORT retrieval path — all gated on the anti-amplification grant machinery (§3.4) — and a real admin UI. + +## Production app — echolot-app/, core-protocol proven live (2026-07-31) +Started the Android client, bottom-up from the verifiable spine. `echolot-app/` is a multi-module +Gradle build; `core-protocol` is a **pure Kotlin/JVM** module (no Android SDK) implementing the +client half of probe-protocol.md: SPKI-pinned control plane (enroll/profile/session via +HttpsURLConnection — Android-API-1 compatible, hostname verification off since trust is the pin), +HKDF-SHA256 session keys, and the ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) — +byte-compatible with the Go server. Unit tests pass incl. the RFC 5869 HKDF vector (so key +derivation provably matches the server). **Verified END-TO-END against live fmr** via +`scripts/test-fmr.sh` (mint token over SSH → enroll on public control plane → run LiveServerTest): +profile (8 caps), session, ECHO rtt ~11ms with the observation block round-tripping the client's +observed NAT port, MTU probe 1400→1400, observations 298B. Two client bugs found+fixed doing it: +java.net.http did hostname verification (switched to HttpsURLConnection) and ECHO needed ≥72-byte +requests for the full 40-byte observation to survive §3.4 anti-amplification. Next: core-measurement +(schema types), core-probe (port prober probes), core-shizuku (dual-path), Compose UI. diff --git a/echolot-app/.gitignore b/echolot-app/.gitignore new file mode 100644 index 0000000..eefcc48 --- /dev/null +++ b/echolot-app/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +/local.properties +/.idea/ +*.iml +.DS_Store diff --git a/echolot-app/README.md b/echolot-app/README.md new file mode 100644 index 0000000..94fb656 --- /dev/null +++ b/echolot-app/README.md @@ -0,0 +1,35 @@ +# Echolot app + +The production Android client ([spec](../docs/)). Native Kotlin + Jetpack Compose. Multi-module; +built bottom-up from a verifiable protocol spine. + +## Modules + +| Module | Type | Status | +|---|---|---| +| `core-protocol` | pure Kotlin/JVM | **done** — client half of `probe-protocol.md`, verified live against the server | +| `core-measurement` | pure Kotlin/JVM | planned — `measurement-schema.md` types | +| `core-probe` | Android lib | planned — app-tier probes, ported from `echolot-prober` | +| `core-shizuku` | Android lib | planned — dual-path executor (UserService + newProcess fallback) | +| `app` | Android app | planned — Compose UI | + +`core-protocol` is deliberately Android-free so it builds and unit-tests on any JDK (no Android +SDK) and can run **integration tests against a live server**. + +## core-protocol + +Implements the control plane (SPKI-pinned enrollment/profile/sessions via `HttpsURLConnection` — +Android-API-1 compatible, hostname verification off because trust is the pin), the HKDF-SHA256 +session-key schedule, and the binary ELT1 UDP data plane (HMAC gate, ECHO + observation block, +MTU probe) — byte-compatible with the Go server. + +```sh +./gradlew :core-protocol:test # unit tests (crypto vectors, wire round-trip) +scripts/test-fmr.sh # live end-to-end test against the deployed server +``` + +`test-fmr.sh` mints an enrollment token over SSH, enrolls via the public control plane, computes +the SPKI pin from the served cert, and runs `LiveServerTest` — proving the client speaks the wire +protocol to the real server (enroll → profile → session → echo+observation → MTU → observations). +The live test self-skips when `ECHOLOT_LIVE_*` env vars are absent, so unit runs and CI stay green +offline. diff --git a/echolot-app/build.gradle.kts b/echolot-app/build.gradle.kts new file mode 100644 index 0000000..b061d53 --- /dev/null +++ b/echolot-app/build.gradle.kts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/echolot-app/core-protocol/build.gradle.kts b/echolot-app/core-protocol/build.gradle.kts new file mode 100644 index 0000000..ef706f0 --- /dev/null +++ b/echolot-app/core-protocol/build.gradle.kts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so +// the Android app modules can depend on it and it stays unit-testable (incl. +// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK +// (javax.crypto, java.net.http, java.net) — only JSON needs a library. +dependencies { + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +kotlin { + // Build with the available JDK (Android Studio's JBR is 21) but emit + // Java-17 bytecode so the Android app modules can consume this library. + jvmToolchain(21) + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.test { + useJUnitPlatform() + // The live end-to-end test against a real server only runs when + // ECHOLOT_LIVE_URL is set; otherwise it self-skips (see LiveServerTest). + listOf("ECHOLOT_LIVE_URL", "ECHOLOT_LIVE_PIN", "ECHOLOT_LIVE_CRED", + "ECHOLOT_LIVE_UDP", "ECHOLOT_LIVE_TARGET").forEach { k -> + System.getenv(k)?.let { environment(k, it) } + } +} diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt new file mode 100644 index 0000000..8275afc --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt @@ -0,0 +1,92 @@ +// 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)) + } + + 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/src/main/kotlin/app/echo_lot/protocol/Crypto.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Crypto.kt new file mode 100644 index 0000000..051a3eb --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Crypto.kt @@ -0,0 +1,53 @@ +// 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/src/main/kotlin/app/echo_lot/protocol/Model.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt new file mode 100644 index 0000000..84b5b16 --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt @@ -0,0 +1,64 @@ +// 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/src/main/kotlin/app/echo_lot/protocol/Pinning.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Pinning.kt new file mode 100644 index 0000000..f25d689 --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Pinning.kt @@ -0,0 +1,39 @@ +// 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/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt new file mode 100644 index 0000000..9961cb4 --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt @@ -0,0 +1,77 @@ +// 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) + } + + 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/src/main/kotlin/app/echo_lot/protocol/Wire.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Wire.kt new file mode 100644 index 0000000..4a9a79d --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Wire.kt @@ -0,0 +1,115 @@ +// 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 + + /** 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/src/test/kotlin/app/echo_lot/protocol/CryptoWireTest.kt b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/CryptoWireTest.kt new file mode 100644 index 0000000..ccc7b71 --- /dev/null +++ b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/CryptoWireTest.kt @@ -0,0 +1,76 @@ +// 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/src/test/kotlin/app/echo_lot/protocol/LiveServerTest.kt b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/LiveServerTest.kt new file mode 100644 index 0000000..5f63958 --- /dev/null +++ b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/LiveServerTest.kt @@ -0,0 +1,66 @@ +// 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) + } +} diff --git a/echolot-app/gradle/libs.versions.toml b/echolot-app/gradle/libs.versions.toml new file mode 100644 index 0000000..6149c8a --- /dev/null +++ b/echolot-app/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +[versions] +kotlin = "2.2.10" +kotlinxSerialization = "1.7.3" + +[libraries] +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/echolot-app/gradle/wrapper/gradle-wrapper.jar b/echolot-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/echolot-app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/echolot-app/gradle/wrapper/gradle-wrapper.properties b/echolot-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7e7d24f --- /dev/null +++ b/echolot-app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/echolot-app/gradlew b/echolot-app/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/echolot-app/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/echolot-app/gradlew.bat b/echolot-app/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/echolot-app/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/echolot-app/scripts/test-fmr.sh b/echolot-app/scripts/test-fmr.sh new file mode 100644 index 0000000..052fb8d --- /dev/null +++ b/echolot-app/scripts/test-fmr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Echolot contributors +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Runs core-protocol's LiveServerTest against the deployed fmr server: mints an +# enrollment token over SSH (admin is localhost-only), enrolls over the public +# control plane, computes the SPKI pin from the served cert, and hands the +# whole lot to the Gradle test. Proves the Kotlin client talks to the real +# server over the wire. +# +# Usage: JAVA_HOME=... echolot-app/scripts/test-fmr.sh +set -euo pipefail + +SSH_HOST="${ECHOLOT_SSH:-claude-echolot}" +CTL_HOST="${ECHOLOT_CTL_HOST:-fmr-1.echo-lot.app}" +CTL_PORT="${ECHOLOT_CTL_PORT:-8443}" +UDP_PORT="${ECHOLOT_UDP_PORT:-8442}" +CTL_URL="https://${CTL_HOST}:${CTL_PORT}" + +echo "· minting enrollment token on ${SSH_HOST} ..." +TOKEN=$(ssh -o BatchMode=yes "$SSH_HOST" \ + 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \ + | python -c 'import json,sys;print(json.load(sys.stdin)["token"])') + +echo "· enrolling over ${CTL_URL} ..." +CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN}" \ + | python -c 'import json,sys;print(json.load(sys.stdin)["credential"])') + +echo "· computing SPKI pin from served cert ..." +PIN=$(echo | openssl s_client -connect "${CTL_HOST}:${CTL_PORT}" 2>/dev/null \ + | openssl x509 -pubkey -noout \ + | openssl pkey -pubin -outform der 2>/dev/null \ + | openssl dgst -sha256 -binary | openssl base64) + +echo "· pin=${PIN}" +echo "· running LiveServerTest ..." +cd "$(dirname "$0")/.." +ECHOLOT_LIVE_URL="$CTL_URL" \ +ECHOLOT_LIVE_PIN="$PIN" \ +ECHOLOT_LIVE_CRED="$CRED" \ +ECHOLOT_LIVE_UDP="${CTL_HOST}:${UDP_PORT}" \ +ECHOLOT_LIVE_TARGET="${ECHOLOT_LIVE_TARGET:-fmr}" \ + ./gradlew :core-protocol:test --tests '*LiveServerTest*' --info --rerun-tasks --console=plain \ + 2>&1 | grep -E "profile:|session:|echo |mtu probe|observations bytes|LiveServerTest|BUILD|FAIL|PASS" || true diff --git a/echolot-app/settings.gradle.kts b/echolot-app/settings.gradle.kts new file mode 100644 index 0000000..0781d80 --- /dev/null +++ b/echolot-app/settings.gradle.kts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "echolot-app" + +// core-protocol is a pure Kotlin/JVM module (the client side of +// probe-protocol.md) so it builds and unit-tests without the Android SDK and +// can run integration tests against a live server. Android modules +// (core-probe, core-shizuku, app) join as they land. +include(":core-protocol")