From 59ba1c16bc2e5d1a3fd6b981d10b122e981c1413 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 09:02:43 +0200 Subject: [PATCH] =?UTF-8?q?app:=20dns.canary=20probe=20=E2=80=94=20client?= =?UTF-8?q?=20half=20of=20the=20canary=20measurement,=20verified=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the server's canary zone through the platform resolver and compares against the spec-frozen ground truth (probe-protocol §6.1): reference records detect answers rewritten in flight, and a per-run nonce name (uncacheable) proves the query reached the authoritative server. Findings: dns.answer_rewritten (high), dns.authoritative_unreachable (medium). Verified on the OnePlus against the deployed fmr zone: 4/4 reference records matched exactly, nonce name answered 192.0.2.21 with reached_authoritative=true. First full client<->server measurement loop on real hardware; report archived. Co-Authored-By: Claude Opus 5 --- docs/build-status.md | 14 + .../kotlin/app/echo_lot/app/RunViewModel.kt | 28 ++ .../app/echo_lot/engine/ServerMeasurement.kt | 166 ++++++++ .../bin/main/app/echo_lot/engine/Support.kt | 38 ++ .../echo_lot/engine/LiveMeasurementTest.kt | 63 +++ .../main/app/echo_lot/measurement/Document.kt | 100 +++++ .../main/app/echo_lot/measurement/Evidence.kt | 85 ++++ .../main/app/echo_lot/measurement/Finding.kt | 68 ++++ .../main/app/echo_lot/measurement/Network.kt | 113 +++++ .../main/app/echo_lot/measurement/Summary.kt | 90 ++++ .../bin/main/app/echo_lot/measurement/Test.kt | 147 +++++++ .../echo_lot/measurement/SerializationTest.kt | 71 ++++ .../app/echo_lot/measurement/VerdictsTest.kt | 109 +++++ .../app/echo_lot/probe/DnsCanaryProbe.kt | 111 +++++ .../app/echo_lot/protocol/ControlClient.kt | 92 +++++ .../bin/main/app/echo_lot/protocol/Crypto.kt | 53 +++ .../bin/main/app/echo_lot/protocol/Model.kt | 64 +++ .../bin/main/app/echo_lot/protocol/Pinning.kt | 39 ++ .../app/echo_lot/protocol/ProbeSession.kt | 77 ++++ .../bin/main/app/echo_lot/protocol/Wire.kt | 115 ++++++ .../app/echo_lot/protocol/CryptoWireTest.kt | 76 ++++ .../app/echo_lot/protocol/LiveServerTest.kt | 66 +++ echolot-app/reports/CPH2747-app-run2-dns.json | 385 ++++++++++++++++++ 23 files changed, 2170 insertions(+) create mode 100644 echolot-app/core-engine/bin/main/app/echo_lot/engine/ServerMeasurement.kt create mode 100644 echolot-app/core-engine/bin/main/app/echo_lot/engine/Support.kt create mode 100644 echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveMeasurementTest.kt create mode 100644 echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Document.kt create mode 100644 echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Evidence.kt create mode 100644 echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Finding.kt create mode 100644 echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Network.kt create mode 100644 echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Summary.kt create mode 100644 echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt create mode 100644 echolot-app/core-measurement/bin/test/app/echo_lot/measurement/SerializationTest.kt create mode 100644 echolot-app/core-measurement/bin/test/app/echo_lot/measurement/VerdictsTest.kt create mode 100644 echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt create mode 100644 echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ControlClient.kt create mode 100644 echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Crypto.kt create mode 100644 echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Model.kt create mode 100644 echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Pinning.kt create mode 100644 echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ProbeSession.kt create mode 100644 echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Wire.kt create mode 100644 echolot-app/core-protocol/bin/test/app/echo_lot/protocol/CryptoWireTest.kt create mode 100644 echolot-app/core-protocol/bin/test/app/echo_lot/protocol/LiveServerTest.kt create mode 100644 echolot-app/reports/CPH2747-app-run2-dns.json diff --git a/docs/build-status.md b/docs/build-status.md index dd8b81f..68d4276 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -388,3 +388,17 @@ at `echolot-app/reports/CPH2747-app-run1.json`. Results: Also fixed this session: the beacon app itself caused the "wireless debugging connected" notification spam (it re-resolved adbd's own mDNS advertisement, making adbd re-arm each time); now resolves once per service instance and the heartbeat re-POSTs the cached port only. + +## App: dns.canary verified against the live server (2026-08-01) +Built the client half of the canary-DNS measurement and verified it on the OnePlus against the +deployed fmr zone (`echolot-app/reports/CPH2747-app-run2-dns.json`): +- All four spec-frozen reference records matched byte-for-byte through the network's own resolver + (ttl-5→192.0.2.5, ttl-60→192.0.2.60, ttl-3600→192.0.2.36, ttl-86400→192.0.2.86) → nothing on + this path rewrites DNS answers (`dns.answer_integrity` in the green case). +- The un-cacheable nonce name `1006ad16.adhoc.c.echo-lot.app` resolved to 192.0.2.21 → + `reached_authoritative: true`, proving the query actually reached the canary server rather than + being answered from a cache or an interceptor. +Findings wired: `dns.answer_rewritten` (high) when a reference mismatches, and +`dns.authoritative_unreachable` (medium) when the nonce isn't answered by the canary server. +This closes the first full client↔server measurement loop: the Kotlin app measures against the Go +server's canary zone on real hardware. 6 tests now run per measurement. diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 618e1bf..382fd4e 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import app.echo_lot.measurement.* import app.echo_lot.probe.CaptivePortalProbe +import app.echo_lot.probe.DnsCanaryProbe import app.echo_lot.probe.IcmpProbe import app.echo_lot.probe.LinkSnapshotProbe import app.echo_lot.probe.NetworkInventory @@ -71,6 +72,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { IcmpProbe(entries, v6 = false), IcmpProbe(entries, v6 = true), CaptivePortalProbe(entries), + // Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to + // the reference deployment until profiles/enrollment land in the UI. + DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"), ) val tests = ArrayList() @@ -157,6 +161,30 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { ) } } + if (t.type == TestType.DNS_CANARY) { + val ev = t.evidence?.toString() ?: "" + if (ev.contains("MISMATCH")) { + out.add( + Finding( + id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS, + severity = Severity.HIGH, confidence = Confidence.HIGH, + title = "DNS answers are being rewritten", + description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).", + evidenceRefs = listOf(EvidenceRef(t.id)), + ) + ) + } else if (ev.contains("\"reached_authoritative\":false")) { + out.add( + Finding( + id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS, + severity = Severity.MEDIUM, confidence = Confidence.MEDIUM, + title = "Canary queries don't reach the authoritative server", + description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.", + evidenceRefs = listOf(EvidenceRef(t.id)), + ) + ) + } + } if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) { out.add( Finding( 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 new file mode 100644 index 0000000..bc1ba68 --- /dev/null +++ b/echolot-app/core-engine/bin/main/app/echo_lot/engine/ServerMeasurement.kt @@ -0,0 +1,166 @@ +// 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 new file mode 100644 index 0000000..2fc50cf --- /dev/null +++ b/echolot-app/core-engine/bin/main/app/echo_lot/engine/Support.kt @@ -0,0 +1,38 @@ +// 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/LiveMeasurementTest.kt b/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveMeasurementTest.kt new file mode 100644 index 0000000..1619b38 --- /dev/null +++ b/echolot-app/core-engine/bin/test/app/echo_lot/engine/LiveMeasurementTest.kt @@ -0,0 +1,63 @@ +// 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-measurement/bin/main/app/echo_lot/measurement/Document.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Document.kt new file mode 100644 index 0000000..c482607 --- /dev/null +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Document.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package measurement models one measurement run (measurement-schema.md) — the archived, +// diffable, exportable unit. Design rules honored in the types: observation/interpretation +// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math), +// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are +// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields). +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class MeasurementDocument( + val schema: String = "echolot/measurement", + @SerialName("schema_version") val schemaVersion: String = "1.0.0", + val run: Run, + val networks: List = emptyList(), + @SerialName("server_sessions") val serverSessions: List = emptyList(), + val tests: List = emptyList(), + val findings: List = emptyList(), + val summary: Summary? = null, +) + +@Serializable +data class Run( + val id: String, // UUIDv7 + val trigger: Trigger, + @SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only + @SerialName("ended_at") val endedAt: String? = null, + val clock: Clock, + val app: AppInfo, + val device: DeviceInfo, + val tiers: Tiers, + @SerialName("profiles_used") val profilesUsed: List = emptyList(), + val notes: String? = null, +) + +@Serializable +enum class Trigger { + @SerialName("manual") MANUAL, + @SerialName("scheduled") SCHEDULED, + @SerialName("monitor") MONITOR, + @SerialName("peer") PEER, +} + +/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans; + * all math uses *_mono_ns relative to that monotonic origin. */ +@Serializable +data class Clock( + @SerialName("mono_origin_wall") val monoOriginWall: String, + @SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null, + @SerialName("ntp_offset_source") val ntpOffsetSource: String? = null, +) + +@Serializable +data class AppInfo( + val version: String, + val build: Int, + val git: String? = null, + val flavor: String? = null, +) + +@Serializable +data class DeviceInfo( + val manufacturer: String, + val model: String, + @SerialName("android_sdk") val androidSdk: Int, + @SerialName("android_release") val androidRelease: String, + @SerialName("security_patch") val securityPatch: String? = null, +) + +/** What each tier was *available*; each test records what it *used*. */ +@Serializable +data class Tiers( + val app: Boolean = true, + val shizuku: Boolean = false, + val root: Boolean = false, +) + +@Serializable +data class ServerSession( + val id: String, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("profile_name") val profileName: String? = null, + @SerialName("control_url") val controlUrl: String, + @SerialName("server_version") val serverVersion: String? = null, + val capabilities: List = emptyList(), + @SerialName("session_id") val sessionId: String, + val target: SessionTarget, +) + +@Serializable +data class SessionTarget( + val ip4: String? = null, + val ip6: String? = null, + @SerialName("udp_port") val udpPort: Int = 0, +) diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Evidence.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Evidence.kt new file mode 100644 index 0000000..2580453 --- /dev/null +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Evidence.kt @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe + * code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here + * means the columnar/traceroute/resolver contracts live in one place. + */ + +@PublishedApi +internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true } + +/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */ +inline fun T.toEvidence(): JsonObject = + evidenceJson.encodeToJsonElement(this) as JsonObject + +/** + * Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing + * observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server + * columns use the server session epoch; only differences within one clock are meaningful unless a + * time.server_offset test maps them. + */ +@Serializable +data class TrainEvidence( + @SerialName("epoch_mono_ns") val epochMonoNs: Long, + val seq: List, + @SerialName("t_tx_ns") val tTxNs: List, + @SerialName("t_srv_rx_ns") val tSrvRxNs: List = emptyList(), + @SerialName("t_srv_tx_ns") val tSrvTxNs: List = emptyList(), + @SerialName("t_rx_ns") val tRxNs: List, + @SerialName("size_bytes") val sizeBytes: List, + @SerialName("dscp_sent") val dscpSent: Int? = null, + @SerialName("dscp_seen_by_server") val dscpSeenByServer: List = emptyList(), + @SerialName("ecn_sent") val ecnSent: Int? = null, + @SerialName("ecn_seen_by_server") val ecnSeenByServer: List = emptyList(), + @SerialName("ttl_seen_by_server") val ttlSeenByServer: List = emptyList(), + @SerialName("evidence_truncated") val evidenceTruncated: Boolean = false, +) + +/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */ +@Serializable +data class TracerouteEvidence(val flow: Flow, val hops: List) + +@Serializable +data class Flow( + @SerialName("src_port") val srcPort: Int, + @SerialName("dst_port") val dstPort: Int, + @SerialName("fixed_tuple") val fixedTuple: Boolean = true, +) + +@Serializable +data class Hop(val ttl: Int, val probes: List) + +@Serializable +data class HopProbe( + @SerialName("reply_from") val replyFrom: String? = null, + @SerialName("rtt_ns") val rttNs: Long? = null, + val icmp: String? = null, + @SerialName("reply_ttl") val replyTtl: Int? = null, +) + +/** Resolver under test (§6.4); every dns.* test carries this in params. */ +@Serializable +data class ResolverSpec( + val source: ResolverSource, + val address: String? = null, + val port: Int = 53, + val transport: String, // do53-udp | do53-tcp | dot | doh + @SerialName("doh_url") val dohUrl: String? = null, +) + +@Serializable +enum class ResolverSource { + @SerialName("system") SYSTEM, + @SerialName("manual") MANUAL, + @SerialName("server-recursive") SERVER_RECURSIVE, +} diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Finding.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Finding.kt new file mode 100644 index 0000000..5020313 --- /dev/null +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Finding.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with + * no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */ +@Serializable +data class Finding( + val id: String, // UUIDv7 + val code: String, // stable registry (findings-registry.md), lint-rule style + val category: Category, + val severity: Severity, + val confidence: Confidence, + @SerialName("network_ref") val networkRef: String? = null, + val title: String, + val description: String, + @SerialName("evidence_refs") val evidenceRefs: List, + val recommendation: String? = null, +) { + init { + require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" } + } +} + +@Serializable +data class EvidenceRef(val test: String, val pointer: String? = null) + +/** Fixed §7.2 categories; each maps to one traffic light. */ +@Serializable +enum class Category { + @SerialName("connectivity") CONNECTIVITY, + @SerialName("dns") DNS, + @SerialName("nat") NAT, + @SerialName("mtu") MTU, + @SerialName("ipv6") IPV6, + @SerialName("security") SECURITY, + @SerialName("performance") PERFORMANCE, + @SerialName("local") LOCAL, + @SerialName("wifi") WIFI, +} + +/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */ +@Serializable +enum class Severity(val rank: Int) { + @SerialName("critical") CRITICAL(4), + @SerialName("high") HIGH(3), + @SerialName("medium") MEDIUM(2), + @SerialName("low") LOW(1), + @SerialName("info") INFO(0); + + /** §7.3: critical|high → red, medium|low → yellow, info → green. */ + fun toLight(): Verdict = when (this) { + CRITICAL, HIGH -> Verdict.RED + MEDIUM, LOW -> Verdict.YELLOW + INFO -> Verdict.GREEN + } +} + +@Serializable +enum class Confidence { + @SerialName("high") HIGH, + @SerialName("medium") MEDIUM, + @SerialName("low") LOW, +} diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Network.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Network.kt new file mode 100644 index 0000000..a432eb0 --- /dev/null +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Network.kt @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto, + * lifetimes) are absent at app tier — absence means "not observed", never "not present". */ +@Serializable +data class Network( + val id: String, + val transport: Transport, + @SerialName("interface") val iface: String? = null, + val link: Link, + val wifi: Wifi? = null, + val cellular: Cellular? = null, + val changes: List = emptyList(), +) + +@Serializable +enum class Transport { + @SerialName("wifi") WIFI, + @SerialName("cellular") CELLULAR, + @SerialName("ethernet") ETHERNET, + @SerialName("vpn") VPN, + @SerialName("other") OTHER, +} + +@Serializable +data class Link( + val mtu: Int? = null, + val addresses: List
= emptyList(), + val routes: List = emptyList(), + val dns: DnsConfig? = null, + val dhcp: Dhcp? = null, + @SerialName("captive_portal") val captivePortal: CaptivePortal? = null, +) + +@Serializable +data class Address( + val addr: String, // ip4 | ip6 (logical type, §8) + @SerialName("prefix_len") val prefixLen: Int, + val scope: String? = null, + val flags: List = emptyList(), + @SerialName("valid_lft_s") val validLftS: Long? = null, + @SerialName("pref_lft_s") val prefLftS: Long? = null, +) + +@Serializable +data class Route( + val dst: String, + val gateway: String? = null, + val iface: String? = null, + val proto: RouteProto? = null, // shizuku tier; null = not observed + @SerialName("expires_s") val expiresS: Long? = null, +) + +@Serializable +enum class RouteProto { + @SerialName("dhcp") DHCP, + @SerialName("ra") RA, + @SerialName("static") STATIC, + @SerialName("unknown") UNKNOWN, +} + +@Serializable +data class DnsConfig( + val servers: List = emptyList(), + @SerialName("private_dns_mode") val privateDnsMode: String? = null, + @SerialName("private_dns_hostname") val privateDnsHostname: String? = null, + @SerialName("search_domains") val searchDomains: List = emptyList(), + @SerialName("nat64_prefix") val nat64Prefix: String? = null, +) + +@Serializable +data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null) + +@Serializable +data class CaptivePortal( + val detected: Boolean = false, + @SerialName("api_url") val apiUrl: String? = null, + @SerialName("venue_url") val venueUrl: String? = null, +) + +@Serializable +data class Wifi( + val ssid: String? = null, // ssid (logical type) + val bssid: String? = null, // bssid (logical type) + @SerialName("rssi_dbm") val rssiDbm: Int? = null, + @SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null, + @SerialName("frequency_mhz") val frequencyMhz: Int? = null, + @SerialName("channel_width_mhz") val channelWidthMhz: Int? = null, + val standard: String? = null, + val security: String? = null, + @SerialName("mac_randomization") val macRandomization: Boolean? = null, +) + +@Serializable +data class Cellular( + val rat: String? = null, + val operator: String? = null, + val band: String? = null, +) + +@Serializable +data class NetworkChange( + @SerialName("at_mono_ns") val atMonoNs: Long, + val kind: String, // lost | gained | link_changed + val detail: JsonObject? = null, +) diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Summary.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Summary.kt new file mode 100644 index 0000000..9cf23c3 --- /dev/null +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Summary.kt @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class Verdict { + @SerialName("green") GREEN, + @SerialName("yellow") YELLOW, + @SerialName("red") RED, + @SerialName("inconclusive") INCONCLUSIVE, +} + +@Serializable +data class Summary( + val overall: Verdict, + val categories: Map, +) + +@Serializable +data class CategorySummary( + val verdict: Verdict, + @SerialName("worst_finding") val worstFinding: String? = null, + @SerialName("tests_run") val testsRun: Int, + @SerialName("tests_failed") val testsFailed: Int, +) + +/** + * Deterministic verdict derivation, fixed by measurement-schema.md §7.3: + * + * - A category's verdict = the light of its worst-severity finding + * (critical|high → red, medium|low → yellow, info/none → green). + * - A category is `inconclusive` when > 50% of its tests are failed/unsupported. + * - Overall = the worst category light; `inconclusive` only when ALL categories are. + * + * The mapping test-type → category comes from [TestType.category]. Only categories that have + * findings or tests appear in the summary. + */ +object Verdicts { + + private fun isInconclusiveTest(s: TestStatus) = + s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED + + fun derive(tests: List, findings: List): Summary { + val testsByCat = tests.groupBy { TestType.category(it.type) } + val findingsByCat = findings.groupBy { it.category } + val categories = (testsByCat.keys + findingsByCat.keys) + + val perCat = LinkedHashMap() + for (cat in Category.entries) { + if (cat !in categories) continue + val catTests = testsByCat[cat].orEmpty() + val catFindings = findingsByCat[cat].orEmpty() + + val failed = catTests.count { isInconclusiveTest(it.status) } + val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size + + val worst = catFindings.maxByOrNull { it.severity.rank } + val verdict = when { + inconclusive -> Verdict.INCONCLUSIVE + worst == null -> Verdict.GREEN + else -> worst.severity.toLight() + } + perCat[serialName(cat)] = CategorySummary( + verdict = verdict, + worstFinding = worst?.id, + testsRun = catTests.size, + testsFailed = failed, + ) + } + + val overall = deriveOverall(perCat.values) + return Summary(overall = overall, categories = perCat) + } + + /** Overall = worst light; inconclusive only if every category is inconclusive. */ + private fun deriveOverall(cats: Collection): Verdict { + if (cats.isEmpty()) return Verdict.INCONCLUSIVE + if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE + val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2) + // Non-inconclusive categories decide the overall light. + return cats.filter { it.verdict != Verdict.INCONCLUSIVE } + .maxByOrNull { rank.getValue(it.verdict) }!!.verdict + } + + private fun serialName(cat: Category): String = cat.name.lowercase() +} diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt new file mode 100644 index 0000000..eccec5b --- /dev/null +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test; + * evidence is append-only raw truth; metrics must be recomputable from evidence. All three are + * per-test-type JSON, so they are carried as JsonObject. */ +@Serializable +data class Test( + val id: String, // UUIDv7 + val type: String, // TestType registry (§6.1) + @SerialName("network_ref") val networkRef: String? = null, + @SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests + val tier: Tier, + @SerialName("started_mono_ns") val startedMonoNs: Long, + @SerialName("ended_mono_ns") val endedMonoNs: Long, + val status: TestStatus, + val error: TestError? = null, + val params: JsonObject? = null, + val evidence: JsonObject? = null, + val metrics: JsonObject? = null, +) + +@Serializable +enum class Tier { + @SerialName("app") APP, + @SerialName("shizuku") SHIZUKU, + @SerialName("root") ROOT, +} + +@Serializable +enum class TestStatus { + @SerialName("ok") OK, + @SerialName("failed") FAILED, + @SerialName("unsupported") UNSUPPORTED, + @SerialName("skipped") SKIPPED, + @SerialName("partial") PARTIAL, +} + +@Serializable +data class TestError(val code: String, val detail: String? = null) + +/** + * The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the + * server's measurement-schema test-type registry stay aligned. [category] maps a type to one of + * the fixed §7.2 categories for verdict rollup. + */ +object TestType { + // link + const val LINK_SNAPSHOT = "link.snapshot" + const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch" + const val LINK_IP_MONITOR = "link.ip_monitor" + // net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks) + const val NET_CAPTIVE_PORTAL = "net.captive_portal" + // icmp + const val ICMP_PING4 = "icmp.ping4" + const val ICMP_PING6 = "icmp.ping6" + // trace + const val TRACEROUTE_UDP4 = "traceroute.udp4" + const val TRACEROUTE_UDP6 = "traceroute.udp6" + const val TRACEROUTE_ICMP4 = "traceroute.icmp4" + const val TRACEROUTE_ICMP6 = "traceroute.icmp6" + // train + const val TRAIN_UDP_UPDOWN = "train.udp_updown" + // mtu + const val MTU_PMTUD_UP = "mtu.pmtud_up" + const val MTU_PMTUD_DOWN = "mtu.pmtud_down" + const val MTU_BLACKHOLE = "mtu.blackhole" + const val MTU_MSS_OBSERVED = "mtu.mss_observed" + const val MTU_FRAG_DELIVERY = "mtu.frag_delivery" + // nat + const val NAT_STUN_5780 = "nat.stun_5780" + const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp" + const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp" + const val NAT_HAIRPIN = "nat.hairpin" + const val NAT_CONNECT_BACK = "nat.connect_back" + const val NAT_CGNAT_DETECT = "nat.cgnat_detect" + // dns + const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory" + const val DNS_CANARY = "dns.canary" + const val DNS_INTERCEPTION = "dns.interception" + const val DNS_TTL_INTEGRITY = "dns.ttl_integrity" + const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity" + const val DNS_DNSSEC = "dns.dnssec" + const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard" + const val DNS_REBIND_FILTER = "dns.rebind_filter" + const val DNS_AAAA_FILTER = "dns.aaaa_filter" + const val DNS_DNS64 = "dns.dns64" + const val DNS_COMPARE = "dns.compare" + // sec + const val SEC_TLS_REFERENCE = "sec.tls_reference" + const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo" + const val SEC_HTTP_ECHO = "sec.http_echo" + const val SEC_SNI_FILTER = "sec.sni_filter" + const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival" + const val SEC_ARP_WATCH = "sec.arp_watch" + // port + const val PORT_REACH_SWEEP = "port.reach_sweep" + const val PORT_UDP_USABILITY = "port.udp_usability" + // perf + const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp" + const val PERF_THROUGHPUT_UDP = "perf.throughput_udp" + const val PERF_BUFFERBLOAT = "perf.bufferbloat" + const val PERF_RRC_LATENCY = "perf.rrc_latency" + // v6 + const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare" + const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs" + const val V6_BROKENNESS = "v6.brokenness" + const val V6_NAT64_CLAT = "v6.nat64_clat" + // wifi + const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan" + const val WIFI_ROAM_LOG = "wifi.roam_log" + const val WIFI_SIGNAL_LOG = "wifi.signal_log" + // local + const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory" + const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory" + const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory" + const val LOCAL_GATEWAY_SERVICES = "local.gateway_services" + const val LOCAL_NTP = "local.ntp" + // peer + const val PEER_REACHABILITY = "peer.reachability" + const val PEER_ISOLATION = "peer.isolation" + const val PEER_MULTICAST = "peer.multicast" + const val PEER_LAN_TRAIN = "peer.lan_train" + const val PEER_LEASE_DIFF = "peer.lease_diff" + // time + const val TIME_SERVER_OFFSET = "time.server_offset" + + /** Maps a dotted test type to its §7.2 category for verdict rollup. */ + fun category(type: String): Category = when (type.substringBefore('.')) { + "link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY + "dns" -> Category.DNS + "nat" -> Category.NAT + "mtu" -> Category.MTU + "v6" -> Category.IPV6 + "sec" -> Category.SECURITY + "perf" -> Category.PERFORMANCE + "local", "peer" -> Category.LOCAL + "wifi" -> Category.WIFI + else -> Category.CONNECTIVITY + } +} diff --git a/echolot-app/core-measurement/bin/test/app/echo_lot/measurement/SerializationTest.kt b/echolot-app/core-measurement/bin/test/app/echo_lot/measurement/SerializationTest.kt new file mode 100644 index 0000000..6a23f0b --- /dev/null +++ b/echolot-app/core-measurement/bin/test/app/echo_lot/measurement/SerializationTest.kt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.json.Json +import kotlin.test.Test as JTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SerializationTest { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @JTest + fun documentRoundTrips() { + val doc = MeasurementDocument( + run = Run( + id = "0198c5f2-0000-7000-8000-000000000000", + trigger = Trigger.MANUAL, + startedAt = "2026-07-31T14:03:21.114Z", + clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"), + app = AppInfo(version = "0.1.0", build = 1), + device = DeviceInfo("OnePlus", "CPH2747", 36, "16"), + tiers = Tiers(app = true, shizuku = true), + ), + networks = listOf( + Network( + id = "net-1", transport = Transport.WIFI, iface = "wlan0", + link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))), + wifi = Wifi(ssid = "example", rssiDbm = -54), + ), + ), + tests = listOf( + Test( + id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP, + startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK, + evidence = TrainEvidence( + epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L), + tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64), + ).toEvidence(), + ), + ), + ) + + val encoded = json.encodeToString(MeasurementDocument.serializer(), doc) + val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded) + assertEquals(doc.run.id, decoded.run.id) + assertEquals(Transport.WIFI, decoded.networks[0].transport) + assertEquals(TestType.ICMP_PING4, decoded.tests[0].type) + // snake_case field names on the wire + assertTrue(encoded.contains("\"schema_version\"")) + assertTrue(encoded.contains("\"mono_origin_wall\"")) + assertTrue(encoded.contains("\"t_tx_ns\"")) + // null preserved at train index 1 + assertTrue(encoded.contains("[16500000,null]")) + } + + @JTest + fun findingRequiresEvidence() { + try { + Finding( + id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO, + confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(), + ) + throw AssertionError("expected IllegalArgumentException for empty evidence_refs") + } catch (e: IllegalArgumentException) { + // expected — a finding with no evidence is invalid (§7.1) + } + } +} diff --git a/echolot-app/core-measurement/bin/test/app/echo_lot/measurement/VerdictsTest.kt b/echolot-app/core-measurement/bin/test/app/echo_lot/measurement/VerdictsTest.kt new file mode 100644 index 0000000..9314680 --- /dev/null +++ b/echolot-app/core-measurement/bin/test/app/echo_lot/measurement/VerdictsTest.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlin.test.Test as JTest +import kotlin.test.assertEquals + +class VerdictsTest { + + private fun test(type: String, status: TestStatus, id: String = type): Test = + Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status) + + private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding = + Finding( + id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH, + title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")), + ) + + @JTest + fun categoryLightFromWorstSeverity() { + val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK)) + val findings = listOf( + finding(Category.DNS, Severity.LOW), + finding(Category.DNS, Severity.HIGH), // worst → red + finding(Category.DNS, Severity.INFO), + ) + val s = Verdicts.derive(tests, findings) + assertEquals(Verdict.RED, s.categories["dns"]!!.verdict) + assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding) + assertEquals(Verdict.RED, s.overall) + } + + @JTest + fun noFindingsIsGreen() { + val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList()) + assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict) + assertEquals(Verdict.GREEN, s.overall) + } + + @JTest + fun mediumAndLowAreYellow() { + val s = Verdicts.derive( + listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)), + listOf(finding(Category.SECURITY, Severity.MEDIUM)), + ) + assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict) + } + + @JTest + fun majorityFailedIsInconclusive() { + // 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present. + val tests = listOf( + test(TestType.DNS_CANARY, TestStatus.FAILED, "a"), + test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"), + test(TestType.DNS_COMPARE, TestStatus.OK, "c"), + ) + val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH))) + assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict) + assertEquals(2, s.categories["dns"]!!.testsFailed) + assertEquals(3, s.categories["dns"]!!.testsRun) + } + + @JTest + fun exactlyHalfFailedIsNotInconclusive() { + // 1 of 2 failed → not > 50% → the finding decides. + val tests = listOf( + test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"), + test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"), + ) + val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL))) + assertEquals(Verdict.RED, s.categories["nat"]!!.verdict) + } + + @JTest + fun overallIsWorstCategory() { + val tests = listOf( + test(TestType.DNS_CANARY, TestStatus.OK, "d"), + test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), + ) + val findings = listOf( + finding(Category.DNS, Severity.MEDIUM), // yellow + finding(Category.MTU, Severity.CRITICAL), // red + ) + val s = Verdicts.derive(tests, findings) + assertEquals(Verdict.RED, s.overall) + } + + @JTest + fun overallInconclusiveOnlyWhenAllAre() { + val tests = listOf( + test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive + test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green + ) + val s = Verdicts.derive(tests, emptyList()) + assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict) + assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict) + assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides + } + + @JTest + fun categoryMappingCoversFamilies() { + assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4)) + assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS)) + assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST)) + assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT)) + assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET)) + } +} diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt new file mode 100644 index 0000000..28d887f --- /dev/null +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/DnsCanaryProbe.kt @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.probe + +import android.content.Context +import app.echo_lot.measurement.Test +import app.echo_lot.measurement.TestStatus +import app.echo_lot.measurement.TestType +import app.echo_lot.measurement.Tier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.net.InetAddress + +/** + * dns.canary / dns.answer_integrity — resolves the server's canary zone through the network's own + * resolver and compares against the spec-frozen ground truth (probe-protocol.md §6.1). + * + * Two things are checked, and they detect different failures: + * - **Reference records** (`ttl-5`, `many-rr`, …) have FIXED RDATA fixed by the spec, so a + * mismatch means the answer was rewritten in flight (interception/filtering). + * - A **per-run nonce name** `..` can never have been cached, so it proves + * the query reached the authoritative server, and the answer is derived from the nonce itself. + * + * Resolution goes through the platform resolver (InetAddress), i.e. exactly the path apps use — + * so interception by the network's DNS is what we measure. The server side records who actually + * asked (its observation API), letting the app pair "what I got" with "who asked". + */ +class DnsCanaryProbe( + private val canaryZone: String, + private val sessionPrefix: String, + private val nonce: String = java.util.UUID.randomUUID().toString().take(8), +) : Probe { + override val type = TestType.DNS_CANARY + override val tier = Tier.APP + + /** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */ + private val references = listOf( + Reference("ttl-5", "192.0.2.5"), + Reference("ttl-60", "192.0.2.60"), + Reference("ttl-3600", "192.0.2.36"), + Reference("ttl-86400", "192.0.2.86"), + ) + + private data class Reference(val label: String, val expectedA: String) + + override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { + val b = TestBuilder(type, tier, ids) + if (canaryZone.isBlank()) { + return@withContext b.build( + TestStatus.SKIPPED, + evidence = buildJsonObject { put("reason", "no canary zone configured (needs a server profile)") }, + ) + } + + var matched = 0 + var mismatched = 0 + var failed = 0 + + val evidence: JsonObject = buildJsonObject { + put("zone", canaryZone) + putJsonObject("reference_records") { + for (r in references) { + val fqdn = "${r.label}.$canaryZone" + val got = resolveA(fqdn) + putJsonObject(r.label) { + put("fqdn", fqdn); put("expected", r.expectedA); put("got", got ?: "") + val verdict = when { + got == null -> { failed++; "resolve_failed" } + got == r.expectedA -> { matched++; "match" } + else -> { mismatched++; "MISMATCH (answer rewritten in flight)" } + } + put("verdict", verdict) + } + } + } + // Cache-miss proof: a nonce name that cannot have been pre-cached. + val nonceFqdn = "$nonce.$sessionPrefix.$canaryZone" + val nonceGot = resolveA(nonceFqdn) + putJsonObject("nonce_query") { + put("fqdn", nonceFqdn) + put("got", nonceGot ?: "") + // The server answers nonce names from 192.0.2.0/24 (deterministic per nonce). + val reached = nonceGot?.startsWith("192.0.2.") == true + put("reached_authoritative", reached) + put("note", "a non-192.0.2.x answer means something other than the canary server replied") + } + } + + val metrics = buildJsonObject { + put("references_matched", matched); put("references_mismatched", mismatched) + put("references_failed", failed) + } + val status = when { + mismatched > 0 -> TestStatus.PARTIAL // answers altered — a finding + matched == 0 -> TestStatus.FAILED // nothing resolved + failed > 0 -> TestStatus.PARTIAL + else -> TestStatus.OK + } + b.build(status, evidence = evidence, metrics = metrics) + } + + /** First IPv4 answer via the platform resolver (the path a normal app takes), or null. */ + private fun resolveA(fqdn: String): String? = runCatching { + InetAddress.getAllByName(fqdn).firstOrNull { it is java.net.Inet4Address }?.hostAddress + }.getOrNull() +} 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 new file mode 100644 index 0000000..8275afc --- /dev/null +++ b/echolot-app/core-protocol/bin/main/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/bin/main/app/echo_lot/protocol/Crypto.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Crypto.kt new file mode 100644 index 0000000..051a3eb --- /dev/null +++ b/echolot-app/core-protocol/bin/main/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/bin/main/app/echo_lot/protocol/Model.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Model.kt new file mode 100644 index 0000000..84b5b16 --- /dev/null +++ b/echolot-app/core-protocol/bin/main/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/bin/main/app/echo_lot/protocol/Pinning.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Pinning.kt new file mode 100644 index 0000000..f25d689 --- /dev/null +++ b/echolot-app/core-protocol/bin/main/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/bin/main/app/echo_lot/protocol/ProbeSession.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/ProbeSession.kt new file mode 100644 index 0000000..9961cb4 --- /dev/null +++ b/echolot-app/core-protocol/bin/main/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/bin/main/app/echo_lot/protocol/Wire.kt b/echolot-app/core-protocol/bin/main/app/echo_lot/protocol/Wire.kt new file mode 100644 index 0000000..4a9a79d --- /dev/null +++ b/echolot-app/core-protocol/bin/main/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/bin/test/app/echo_lot/protocol/CryptoWireTest.kt b/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/CryptoWireTest.kt new file mode 100644 index 0000000..ccc7b71 --- /dev/null +++ b/echolot-app/core-protocol/bin/test/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/bin/test/app/echo_lot/protocol/LiveServerTest.kt b/echolot-app/core-protocol/bin/test/app/echo_lot/protocol/LiveServerTest.kt new file mode 100644 index 0000000..5f63958 --- /dev/null +++ b/echolot-app/core-protocol/bin/test/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/reports/CPH2747-app-run2-dns.json b/echolot-app/reports/CPH2747-app-run2-dns.json new file mode 100644 index 0000000..b78673e --- /dev/null +++ b/echolot-app/reports/CPH2747-app-run2-dns.json @@ -0,0 +1,385 @@ +{ + "schema": "echolot/measurement", + "schema_version": "1.0.0", + "run": { + "id": "4d92f749-5222-440f-b6a4-def717f0da3b", + "trigger": "manual", + "started_at": "2026-08-01T07:00:58.005733Z", + "ended_at": "2026-08-01T07:01:08.588026Z", + "clock": { + "mono_origin_wall": "2026-08-01T07:00:58.005733Z", + "ntp_offset_ms": null, + "ntp_offset_source": null + }, + "app": { + "version": "0.1.0", + "build": 1, + "git": null, + "flavor": "app" + }, + "device": { + "manufacturer": "OnePlus", + "model": "CPH2747", + "android_sdk": 36, + "android_release": "16", + "security_patch": null + }, + "tiers": { + "app": true, + "shizuku": false, + "root": false + }, + "profiles_used": [], + "notes": null + }, + "networks": [ + { + "id": "net-0", + "transport": "cellular", + "interface": "rmnet_data4", + "link": { + "mtu": 1500, + "addresses": [ + { + "addr": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c", + "prefix_len": 64, + "scope": null, + "flags": [], + "valid_lft_s": null, + "pref_lft_s": null + } + ], + "routes": [ + { + "dst": "::/0", + "gateway": "fe80::246f:12be:21ef:1b54", + "iface": "rmnet_data4", + "proto": null, + "expires_s": null + }, + { + "dst": "2001:4bb8:417:bd78::/64", + "gateway": "::", + "iface": "rmnet_data4", + "proto": null, + "expires_s": null + } + ], + "dns": { + "servers": [ + "fda1:3fb1:0:8:0:10:0:101", + "fda1:3fb1:0:8:0:10:0:100" + ], + "private_dns_mode": "off", + "private_dns_hostname": null, + "search_domains": [], + "nat64_prefix": null + }, + "dhcp": null, + "captive_portal": null + }, + "wifi": null, + "cellular": null, + "changes": [] + }, + { + "id": "net-1", + "transport": "wifi", + "interface": "wlan0", + "link": { + "mtu": null, + "addresses": [ + { + "addr": "fe80::7a:75ff:fee9:ae9e", + "prefix_len": 64, + "scope": null, + "flags": [], + "valid_lft_s": null, + "pref_lft_s": null + }, + { + "addr": "10.13.102.124", + "prefix_len": 24, + "scope": null, + "flags": [], + "valid_lft_s": null, + "pref_lft_s": null + } + ], + "routes": [ + { + "dst": "fe80::/64", + "gateway": "::", + "iface": "wlan0", + "proto": null, + "expires_s": null + }, + { + "dst": "::/0", + "gateway": "fe80::7a9a:18ff:fe54:b8f9", + "iface": "wlan0", + "proto": null, + "expires_s": null + }, + { + "dst": "10.13.102.0/24", + "gateway": "0.0.0.0", + "iface": "wlan0", + "proto": null, + "expires_s": null + }, + { + "dst": "0.0.0.0/0", + "gateway": "10.13.102.1", + "iface": "wlan0", + "proto": null, + "expires_s": null + } + ], + "dns": { + "servers": [ + "10.13.102.1" + ], + "private_dns_mode": "off", + "private_dns_hostname": null, + "search_domains": [ + "hudelist.local" + ], + "nat64_prefix": null + }, + "dhcp": null, + "captive_portal": null + }, + "wifi": null, + "cellular": null, + "changes": [] + } + ], + "server_sessions": [], + "tests": [ + { + "id": "83174b93-b8f3-4c73-86d1-d4d67866ec91", + "type": "link.snapshot", + "network_ref": null, + "session_ref": null, + "tier": "app", + "started_mono_ns": 1990364, + "ended_mono_ns": 2119375, + "status": "ok", + "error": null, + "params": null, + "evidence": { + "network_count": 2, + "networks": [ + { + "id": "net-0", + "transport": "cellular", + "interface": "rmnet_data4", + "mtu": 1500, + "addresses": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c/64", + "dns": "fda1:3fb1:0:8:0:10:0:101, fda1:3fb1:0:8:0:10:0:100", + "nat64": "none" + }, + { + "id": "net-1", + "transport": "wifi", + "interface": "wlan0", + "mtu": 0, + "addresses": "fe80::7a:75ff:fee9:ae9e/64, 10.13.102.124/24", + "dns": "10.13.102.1", + "nat64": "none" + } + ] + }, + "metrics": null + }, + { + "id": "4fcd2393-3464-4d00-a15e-8006e178fefd", + "type": "icmp.ping4", + "network_ref": null, + "session_ref": null, + "tier": "app", + "started_mono_ns": 2260885, + "ended_mono_ns": 86194427, + "status": "ok", + "error": null, + "params": null, + "evidence": { + "target": "1.1.1.1", + "default": "reply type=0 rtt_ms=43.1 bytes=15", + "cellular:net-0": "error: Binding socket to network 137 failed: EPERM (Operation not permitted)", + "wifi:net-1": "reply type=0 rtt_ms=39.3 bytes=15" + }, + "metrics": { + "networks_ok": 2, + "rtt_ms_min": 39.3, + "rtt_ms_avg": 41.2, + "rtt_ms_max": 43.1 + } + }, + { + "id": "5a51060f-6d65-432f-ae2f-09a896f989d2", + "type": "icmp.ping6", + "network_ref": null, + "session_ref": null, + "tier": "app", + "started_mono_ns": 86622343, + "ended_mono_ns": 6407352028, + "status": "failed", + "error": null, + "params": null, + "evidence": { + "target": "2606:4700:4700::1111", + "default": "error: recvfrom failed: EAGAIN (Try again)", + "cellular:net-0": "error: Binding socket to network 137 failed: EPERM (Operation not permitted)", + "wifi:net-1": "error: recvfrom failed: EAGAIN (Try again)" + }, + "metrics": { + "networks_ok": 0 + } + }, + { + "id": "f0e48635-5688-4cdb-9a7c-7e26047427e6", + "type": "net.captive_portal", + "network_ref": null, + "session_ref": null, + "tier": "app", + "started_mono_ns": 6407902080, + "ended_mono_ns": 10451588485, + "status": "ok", + "error": null, + "params": null, + "evidence": { + "https_url": "https://www.google.com/generate_204", + "http_url": "http://connectivitycheck.gstatic.com/generate_204", + "default": { + "https_code": 204, + "http_code": 204, + "verdict": "validated" + }, + "cellular:net-0": { + "https_code": -1, + "http_code": -1, + "verdict": "no_internet" + }, + "wifi:net-1": { + "https_code": 204, + "http_code": 204, + "verdict": "validated" + } + }, + "metrics": null + }, + { + "id": "7811704e-935b-4dd6-ad9c-d97f4bb6db5d", + "type": "dns.canary", + "network_ref": null, + "session_ref": null, + "tier": "app", + "started_mono_ns": 10452107548, + "ended_mono_ns": 10579626037, + "status": "ok", + "error": null, + "params": null, + "evidence": { + "zone": "c.echo-lot.app", + "reference_records": { + "ttl-5": { + "fqdn": "ttl-5.c.echo-lot.app", + "expected": "192.0.2.5", + "got": "192.0.2.5", + "verdict": "match" + }, + "ttl-60": { + "fqdn": "ttl-60.c.echo-lot.app", + "expected": "192.0.2.60", + "got": "192.0.2.60", + "verdict": "match" + }, + "ttl-3600": { + "fqdn": "ttl-3600.c.echo-lot.app", + "expected": "192.0.2.36", + "got": "192.0.2.36", + "verdict": "match" + }, + "ttl-86400": { + "fqdn": "ttl-86400.c.echo-lot.app", + "expected": "192.0.2.86", + "got": "192.0.2.86", + "verdict": "match" + } + }, + "nonce_query": { + "fqdn": "1006ad16.adhoc.c.echo-lot.app", + "got": "192.0.2.21", + "reached_authoritative": true, + "note": "a non-192.0.2.x answer means something other than the canary server replied" + } + }, + "metrics": { + "references_matched": 4, + "references_mismatched": 0, + "references_failed": 0 + } + }, + { + "id": "7612b4c1-615a-4232-9969-7506c2ed77fc", + "type": "link.ip_monitor", + "network_ref": null, + "session_ref": null, + "tier": "shizuku", + "started_mono_ns": 10580257287, + "ended_mono_ns": 10580338173, + "status": "unsupported", + "error": null, + "params": null, + "evidence": { + "binder_alive": false, + "detail": "Shizuku not running" + }, + "metrics": null + } + ], + "findings": [ + { + "id": "5180f756-1bc9-4979-a6f2-4e354a9cba3f", + "code": "ipv6.no_icmp_path", + "category": "ipv6", + "severity": "low", + "confidence": "medium", + "network_ref": null, + "title": "No IPv6 ICMP path on any active network", + "description": "ICMPv6 echo got no reply on any active network — this network has no working IPv6 path (or filters ICMPv6).", + "evidence_refs": [ + { + "test": "5a51060f-6d65-432f-ae2f-09a896f989d2", + "pointer": null + } + ], + "recommendation": null + } + ], + "summary": { + "overall": "yellow", + "categories": { + "connectivity": { + "verdict": "green", + "worst_finding": null, + "tests_run": 5, + "tests_failed": 2 + }, + "dns": { + "verdict": "green", + "worst_finding": null, + "tests_run": 1, + "tests_failed": 0 + }, + "ipv6": { + "verdict": "yellow", + "worst_finding": "5180f756-1bc9-4979-a6f2-4e354a9cba3f", + "tests_run": 0, + "tests_failed": 0 + } + } + } +} \ No newline at end of file