diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index 33c25fa..9c494df 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "app.echo_lot.app" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.2.0" // Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true` // runs a measurement immediately and POSTs the report here (dev collection endpoint). buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"") @@ -48,6 +48,8 @@ dependencies { implementation(project(":core-engine")) implementation(project(":core-probe")) implementation(project(":core-shizuku")) + implementation(project(":core-privacy")) + implementation(project(":core-archive")) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt new file mode 100644 index 0000000..bc0fc8a --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.echo_lot.archive.ArchivedRun +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Archived runs, newest first. + * + * Each row states plainly whether the run left the device, because "is this backed up / did I + * share this?" is the question a history list actually gets asked. + */ +@Composable +fun HistoryScreen( + runs: List, + status: String?, + onOpen: (String) -> Unit, + onUpload: (String) -> Unit, + onDelete: (String) -> Unit, + onBack: () -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onBack) { Text("‹ Back") } + Text("History", style = MaterialTheme.typography.titleLarge) + } + status?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + + if (runs.isEmpty()) { + Text( + "No archived runs yet. Finished runs are kept here automatically unless you turn " + + "archiving off in settings.", + style = MaterialTheme.typography.bodyMedium, + ) + return@Column + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(runs, key = { it.id }) { r -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + r.verdict?.uppercase() ?: "—", + color = verdictTint(r.verdict), + style = MaterialTheme.typography.titleMedium, + ) + Text( + " " + humanTime(r.savedAtEpochMs), + style = MaterialTheme.typography.bodyMedium, + ) + } + Text( + "${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}", + style = MaterialTheme.typography.bodySmall, + ) + Text( + if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}" + else "on this device only", + style = MaterialTheme.typography.bodySmall, + color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB), + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { onOpen(r.id) }) { Text("Export") } + TextButton(onClick = { onUpload(r.id) }) { + Text(if (r.uploaded) "Upload again" else "Upload") + } + TextButton(onClick = { onDelete(r.id) }) { Text("Delete") } + } + } + } + } + } + } +} + +private fun verdictTint(v: String?): Color = when (v?.lowercase()) { + "green", "ok", "pass" -> Color(0xFF7FD17F) + "yellow", "warn" -> Color(0xFFE0C060) + "red", "fail" -> Color(0xFFE07070) + else -> Color(0xFFBBBBBB) +} + +private val stamp: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()) + +private fun humanTime(epochMs: Long): String = stamp.format(Instant.ofEpochMilli(epochMs)) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 9d6d32a..077eb6e 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -18,6 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -26,9 +30,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import androidx.lifecycle.viewmodel.compose.viewModel import app.echo_lot.measurement.* +/** The app's three top-level screens. */ +private enum class Screen { RUN, HISTORY, SETTINGS } + class MainActivity : ComponentActivity() { private val permissionLauncher = @@ -41,13 +50,17 @@ class MainActivity : ComponentActivity() { MaterialTheme(colorScheme = darkColorScheme()) { Surface(color = MaterialTheme.colorScheme.background) { val vm: RunViewModel = viewModel() + // Three flat screens, so a plain state variable beats a navigation library: + // there is no back stack to model beyond "return to the run screen". + var screen by remember { mutableStateOf(Screen.RUN) } + var preview by remember { mutableStateOf(null) } // Automation entry point: // adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true // starts a run immediately and uploads the report, so an unattended // measurement needs no UI tapping and no adb round-trip to collect. val autorun = intent?.getBooleanExtra("autorun", false) == true androidx.compose.runtime.LaunchedEffect(autorun) { - if (autorun) vm.run(upload = true) + if (autorun) vm.run(devUpload = true) } // In autorun the app is a batch job: once the run is done AND the upload // succeeded, show the result briefly, then close so the device is left as it @@ -61,7 +74,42 @@ class MainActivity : ComponentActivity() { finish() } } - EcholotScreen( + when (screen) { + Screen.SETTINGS -> SettingsScreen( + settings = vm.settings, + archivedRuns = vm.state.history.size, + archivedBytes = vm.archivedBytes(), + onApplyRetention = vm::applyRetention, + onDeleteAll = vm::deleteAllRuns, + onPreviewUpload = { + // Preview the newest run, since that is the one the user just made + // and the one they are deciding about. + vm.state.history.firstOrNull()?.let { r -> + lifecycleScope.launch { preview = vm.uploadPreview(r.id) } + } + }, + onBack = { screen = Screen.RUN }, + ) + Screen.HISTORY -> HistoryScreen( + runs = vm.state.history, + status = vm.state.archiveStatus, + onOpen = { id -> + lifecycleScope.launch { + vm.readRun(id)?.let { text -> + startActivity( + Intent.createChooser( + Report.shareJson(this@MainActivity, id, text), + "Export Echolot run", + ) + ) + } + } + }, + onUpload = vm::uploadRun, + onDelete = vm::deleteRun, + onBack = { screen = Screen.RUN }, + ) + Screen.RUN -> EcholotScreen( state = vm.state, onRun = { vm.run() }, onCancel = vm::cancel, @@ -86,7 +134,13 @@ class MainActivity : ComponentActivity() { } }, onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) }, - ) + onOpenSettings = { screen = Screen.SETTINGS }, + onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY }, + ) + } + preview?.let { text -> + UploadPreviewDialog(text) { preview = null } + } } } } @@ -123,6 +177,8 @@ private fun EcholotScreen( onShizukuAction: () -> Unit, onDeveloperOptions: () -> Unit, onExport: (MeasurementDocument) -> Unit, + onOpenSettings: () -> Unit, + onOpenHistory: () -> Unit, ) { Column( Modifier @@ -135,8 +191,15 @@ private fun EcholotScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold) - Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold) + Text("measure, don't guess", + color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + } + TextButton(onClick = onOpenHistory) { Text("History") } + TextButton(onClick = onOpenSettings) { Text("Settings") } + } // Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed — // only users who actually use it get reminded that it must be running. @@ -185,6 +248,10 @@ private fun EcholotScreen( } } + state.archiveStatus?.let { + Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + state.uploadStatus?.let { Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -338,3 +405,24 @@ private fun Dot(color: Color) { private fun SectionTitle(text: String) { Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) } + +/** + * Shows the exact JSON an upload would send. + * + * This exists because an anonymizer the user cannot inspect is just a promise. Being able to + * read the outgoing document — and find their own SSID absent from it — is what makes the + * privacy setting checkable rather than merely stated. + */ +@Composable +private fun UploadPreviewDialog(text: String, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + title = { Text("This is what would be uploaded") }, + text = { + Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) { + Text(text, fontSize = 10.sp, fontFamily = FontFamily.Monospace) + } + }, + ) +} diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt index bd008dd..8a13130 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Report.kt @@ -17,10 +17,18 @@ object Report { fun toJson(doc: MeasurementDocument): String = json.encodeToString(MeasurementDocument.serializer(), doc) - fun share(ctx: Context, doc: MeasurementDocument): Intent { + fun share(ctx: Context, doc: MeasurementDocument): Intent = + shareJson(ctx, doc.run.id, toJson(doc)) + + /** + * Shares an already-serialized run — an archived one, whose bytes must go out exactly as + * stored rather than being re-serialized through the model (which would silently drop + * anything a newer schema version added). + */ + fun shareJson(ctx: Context, runId: String, json: String): Intent { val dir = File(ctx.cacheDir, "reports").apply { mkdirs() } - val file = File(dir, "echolot-run-${doc.run.id}.json") - file.writeText(toJson(doc)) + val file = File(dir, "echolot-run-$runId.json") + file.writeText(json) val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file) return Intent(Intent.ACTION_SEND).apply { type = "application/json" diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt new file mode 100644 index 0000000..e25e60f --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import android.content.Context +import app.echo_lot.archive.ArchivedRun +import app.echo_lot.archive.RunArchive +import app.echo_lot.measurement.MeasurementDocument +import app.echo_lot.privacy.Anonymizer +import app.echo_lot.privacy.PrivacyLevel +import app.echo_lot.privacy.Salt +import app.echo_lot.protocol.ControlClient +import app.echo_lot.protocol.UploadRefused +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import java.io.File +import java.security.SecureRandom + +/** + * Ties together the three things that happen to a finished run: it gets archived, it may get + * anonymized, and it may get uploaded — in that order, and with the archive always holding the + * *unredacted* document. + * + * That ordering is the important decision. The local archive is the user's own data on their own + * device, and redacting it would destroy exactly the detail that makes a week-old run worth + * keeping; the anonymizer exists for the moment data crosses to someone else's machine. So + * redaction happens on the way out, per upload, and the archive is never the lossy copy. + */ +class RunStore(context: Context, private val settings: Settings) { + + private val archive = RunArchive(File(context.filesDir, "runs")) + private val json = Json { encodeDefaults = true; explicitNulls = true } + + fun list(): List = archive.list() + fun read(id: String): String? = archive.read(id) + fun delete(id: String) = archive.delete(id) + fun deleteAll(): Int = archive.deleteAll() + fun totalBytes(): Long = archive.totalBytes() + + /** Archives a finished run under the user's retention policy. Null when archiving is off. */ + fun archive(doc: MeasurementDocument): ArchivedRun? = + archive.save(Report.toJson(doc), settings.retention()) + + /** Applies retention now — e.g. after the user tightens the limits in settings. */ + fun purgeNow() = archive.purge(settings.retention()) + + /** + * Produces exactly the bytes an upload would send, so the UI can show the user their own + * document as the server will see it *before* it goes. "Preview what you're about to share" + * is the only honest way to present an anonymizer: its correctness is not something a user + * should have to take on faith. + */ + fun redactedForUpload(docJson: String, level: PrivacyLevel = settings.privacyLevel): String { + val parsed = runCatching { json.parseToJsonElement(docJson).jsonObject }.getOrNull() + ?: return docJson + return json.encodeToString(JsonObject.serializer(), Anonymizer(level, salt()).anonymize(parsed)) + } + + private fun salt(): Salt = + if (settings.stableSalt) Salt.stable(settings.saltSecret()) + else Salt.perRun(ByteArray(32).also { SecureRandom().nextBytes(it) }) + + sealed interface UploadOutcome { + data class Sent(val serverName: String, val detail: String) : UploadOutcome + /** The operator's policy says no. Not retryable, and not the user's fault. */ + data class Refused(val reason: String) : UploadOutcome + data class Failed(val detail: String) : UploadOutcome + data object NotConfigured : UploadOutcome + } + + /** + * Uploads one archived run to the configured server, redacting first. + * + * The server's advertised minimum wins over the user's preference when it is stricter — a + * server may demand more anonymization than the user chose, never less. Blocking; callers + * run it off the main thread. + */ + fun upload(runId: String): UploadOutcome { + if (!settings.serverConfigured) return UploadOutcome.NotConfigured + val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive") + return try { + val client = ControlClient(settings.serverUrl, setOf(settings.serverPin)) + val profile = client.profile(settings.serverCredential) + profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) } + + val level = PrivacyLevel.max( + settings.privacyLevel, + PrivacyLevel.fromWire(profile.uploads.minAnonymization), + ) + val body = redactedForUpload(docJson, level) + val reply = client.uploadRun(settings.serverCredential, body) + archive.markUploaded(runId, profile.name) + UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}") + } catch (e: UploadRefused) { + UploadOutcome.Refused(e.message ?: "refused by the server") + } catch (t: Throwable) { + UploadOutcome.Failed(t.message ?: t.javaClass.simpleName) + } + } +} 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 3ad4e0a..2109af7 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 @@ -38,6 +38,10 @@ data class UiState( val stepsDone: Int = 0, val stepsTotal: Int = 0, val etaSeconds: Int = 0, + /** Where the finished run went: archived locally, uploaded, or neither (and why). */ + val archiveStatus: String? = null, + /** History, newest first. Refreshed after every run and whenever the history screen opens. */ + val history: List = emptyList(), /** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */ val shizukuNotice: String? = null, val shizukuReady: Boolean = false, @@ -56,6 +60,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { var state by mutableStateOf(UiState()) private set + val settings = Settings(app) + private val store = RunStore(app, settings) + private var runJob: kotlinx.coroutines.Job? = null private val stopShizukuObserver: () -> Unit @@ -92,22 +99,111 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { } /** - * Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the - * collection endpoint so an unattended run can be retrieved without adb. + * Runs one measurement, then archives it and — if the user has turned that on — uploads it. + * + * [devUpload] is the separate autorun/adb path (BuildConfig collection endpoint), kept apart + * from the user-facing upload so a debugging convenience can never be mistaken for, or + * silently satisfy, the consent-gated one. */ - fun run(upload: Boolean = false) { + fun run(devUpload: Boolean = false) { if (state.running) return collected.clear() - state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null) + state = state.copy(running = true, currentStep = "starting", document = null, + uploadStatus = null, archiveStatus = null) runJob = viewModelScope.launch { val doc = withContext(Dispatchers.IO) { measure() } + + step("archiving") + val archived = withContext(Dispatchers.IO) { store.archive(doc) } + var archiveStatus = if (archived != null) { + "archived locally (${store.list().size} runs kept)" + } else { + "not archived — archiving is off in settings" + } + var status: String? = null - if (upload) { + if (devUpload) { state = state.copy(currentStep = "uploading report") val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) } status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}" } - state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status) + if (archived != null && settings.autoUpload) { + state = state.copy(currentStep = "uploading to server") + val outcome = withContext(Dispatchers.IO) { store.upload(archived.id) } + archiveStatus += " · " + describe(outcome) + } + + state = UiState( + running = false, currentStep = null, document = doc, + uploadStatus = status, archiveStatus = archiveStatus, + history = withContext(Dispatchers.IO) { store.list() }, + ) + } + } + + private fun describe(o: RunStore.UploadOutcome): String = when (o) { + is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})" + is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}" + is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}" + RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded" + } + + // ---- history --------------------------------------------------------------------- + + fun refreshHistory() { + viewModelScope.launch { + state = state.copy(history = withContext(Dispatchers.IO) { store.list() }) + } + } + + fun deleteRun(id: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { store.delete(id) } + state = state.copy(history = withContext(Dispatchers.IO) { store.list() }) + } + } + + fun deleteAllRuns() { + viewModelScope.launch { + val n = withContext(Dispatchers.IO) { store.deleteAll() } + state = state.copy( + history = emptyList(), + archiveStatus = "deleted $n archived run(s)", + ) + } + } + + /** Uploads one already-archived run on demand, regardless of the auto-upload setting. */ + fun uploadRun(id: String) { + viewModelScope.launch { + state = state.copy(archiveStatus = "uploading …") + val outcome = withContext(Dispatchers.IO) { store.upload(id) } + state = state.copy( + archiveStatus = describe(outcome), + history = withContext(Dispatchers.IO) { store.list() }, + ) + } + } + + /** The archived document as stored, for export. */ + suspend fun readRun(id: String): String? = withContext(Dispatchers.IO) { store.read(id) } + + /** The exact bytes an upload would send, for the settings screen's preview. */ + suspend fun uploadPreview(id: String): String? = withContext(Dispatchers.IO) { + store.read(id)?.let { store.redactedForUpload(it) } + } + + fun archivedBytes(): Long = store.totalBytes() + + /** Re-applies retention after the user changes the limits. */ + fun applyRetention() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { store.purgeNow() } + state = state.copy( + history = withContext(Dispatchers.IO) { store.list() }, + archiveStatus = if (result.isEmpty) "nothing to purge" + else "purged ${result.removed.size} run(s), freed ${result.freedBytes / 1024} kB", + ) } } @@ -122,6 +218,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { state = UiState( running = false, currentStep = null, document = doc, uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded", + archiveStatus = "partial run — not archived", + history = state.history, ) } diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt new file mode 100644 index 0000000..56f9473 --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import android.content.Context +import android.content.SharedPreferences +import app.echo_lot.archive.RetentionPolicy +import app.echo_lot.privacy.PrivacyLevel +import java.security.SecureRandom + +/** + * User settings for archiving, uploading and anonymization. + * + * Defaults are the conservative reading of "an engineer's tool that still respects the person + * holding it": keep history (that is the point of the archive), never upload without being asked, + * and when uploading, strip identifiers unless the user says this is their own server. + * + * SharedPreferences rather than DataStore because these are a dozen scalars read synchronously at + * the start of a run; a coroutine-flow store would add a dependency and a lifecycle for nothing. + */ +class Settings(context: Context) { + + private val prefs: SharedPreferences = + context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE) + + // ---- archive --------------------------------------------------------------------- + + var archiveEnabled: Boolean + get() = prefs.getBoolean(ARCHIVE_ENABLED, true) + set(v) = prefs.edit().putBoolean(ARCHIVE_ENABLED, v).apply() + + /** 0 = no ceiling. */ + var maxRuns: Int + get() = prefs.getInt(MAX_RUNS, 100) + set(v) = prefs.edit().putInt(MAX_RUNS, v.coerceAtLeast(0)).apply() + + var maxAgeDays: Int + get() = prefs.getInt(MAX_AGE_DAYS, 90) + set(v) = prefs.edit().putInt(MAX_AGE_DAYS, v.coerceAtLeast(0)).apply() + + var maxTotalMb: Int + get() = prefs.getInt(MAX_TOTAL_MB, 64) + set(v) = prefs.edit().putInt(MAX_TOTAL_MB, v.coerceAtLeast(0)).apply() + + fun retention(): RetentionPolicy = RetentionPolicy( + enabled = archiveEnabled, + maxRuns = maxRuns, + maxAgeDays = maxAgeDays, + maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024, + ) + + // ---- upload ---------------------------------------------------------------------- + + /** + * Off by default. Measurement data describes the network the user is standing in; sending it + * anywhere is a decision they make, not one they discover after the fact. + */ + var autoUpload: Boolean + get() = prefs.getBoolean(AUTO_UPLOAD, false) + set(v) = prefs.edit().putBoolean(AUTO_UPLOAD, v).apply() + + /** Anonymization applied before a run leaves the device. Never applied to the local archive. */ + var privacyLevel: PrivacyLevel + get() = PrivacyLevel.fromWire(prefs.getString(PRIVACY_LEVEL, PrivacyLevel.BALANCED.wire)) + set(v) = prefs.edit().putString(PRIVACY_LEVEL, v.wire).apply() + + /** + * Whether pseudonyms stay stable across runs. That makes history diffable ("same SSID as + * last week") and is what someone wants on their own server — but it also produces an + * identifier that links a device's uploads, so it is off unless chosen. + */ + var stableSalt: Boolean + get() = prefs.getBoolean(STABLE_SALT, false) + set(v) = prefs.edit().putBoolean(STABLE_SALT, v).apply() + + /** + * The device-local secret behind stable pseudonyms. Generated once, never leaves the device, + * and clearing it (via [resetSalt]) breaks the link to everything uploaded before. + */ + fun saltSecret(): ByteArray { + prefs.getString(SALT_SECRET, null)?.let { return hex(it) } + val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) } + prefs.edit().putString(SALT_SECRET, fresh.joinToString("") { "%02x".format(it) }).apply() + return fresh + } + + fun resetSalt() = prefs.edit().remove(SALT_SECRET).apply() + + // ---- server ---------------------------------------------------------------------- + + var serverUrl: String + get() = prefs.getString(SERVER_URL, "") ?: "" + set(v) = prefs.edit().putString(SERVER_URL, v.trim()).apply() + + var serverPin: String + get() = prefs.getString(SERVER_PIN, "") ?: "" + set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply() + + var serverCredential: String + get() = prefs.getString(SERVER_CRED, "") ?: "" + set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply() + + val serverConfigured: Boolean + get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank() + + private fun hex(s: String) = ByteArray(s.length / 2) { + ((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte() + } + + private companion object { + const val ARCHIVE_ENABLED = "archive_enabled" + const val MAX_RUNS = "archive_max_runs" + const val MAX_AGE_DAYS = "archive_max_age_days" + const val MAX_TOTAL_MB = "archive_max_total_mb" + const val AUTO_UPLOAD = "auto_upload" + const val PRIVACY_LEVEL = "privacy_level" + const val STABLE_SALT = "stable_salt" + const val SALT_SECRET = "salt_secret" + const val SERVER_URL = "server_url" + const val SERVER_PIN = "server_pin" + const val SERVER_CRED = "server_credential" + } +} diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt new file mode 100644 index 0000000..4956ec7 --- /dev/null +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.app + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import app.echo_lot.privacy.PrivacyLevel + +/** + * Archiving, upload and anonymization settings. + * + * The screen is written to make the consequences legible rather than to look tidy: every toggle + * says what it means for the user's data in a sentence, and the privacy levels are described by + * what survives them, because "balanced" on its own tells nobody anything. + */ +@Composable +fun SettingsScreen( + settings: Settings, + archivedRuns: Int, + archivedBytes: Long, + onApplyRetention: () -> Unit, + onDeleteAll: () -> Unit, + onPreviewUpload: () -> Unit, + onBack: () -> Unit, +) { + // SharedPreferences is not observable, so mirror each value into Compose state and write + // through on change. A dozen scalars; a store with flows would be ceremony for nothing. + var archiveEnabled by remember { mutableStateOf(settings.archiveEnabled) } + var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) } + var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) } + var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) } + var autoUpload by remember { mutableStateOf(settings.autoUpload) } + var privacy by remember { mutableStateOf(settings.privacyLevel) } + var stableSalt by remember { mutableStateOf(settings.stableSalt) } + var serverUrl by remember { mutableStateOf(settings.serverUrl) } + var serverPin by remember { mutableStateOf(settings.serverPin) } + var serverCred by remember { mutableStateOf(settings.serverCredential) } + + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onBack) { Text("‹ Back") } + Text("Settings", style = MaterialTheme.typography.titleLarge) + } + + // ---- archive ---- + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Archive", style = MaterialTheme.typography.titleMedium) + Toggle( + label = "Keep finished runs on this device", + detail = "History is what makes a run comparable later. Archived runs are " + + "stored complete and unredacted — anonymization only applies to uploads.", + checked = archiveEnabled, + ) { archiveEnabled = it; settings.archiveEnabled = it } + + Text( + "Purge automatically when a run exceeds any of these. 0 turns that limit off.", + style = MaterialTheme.typography.bodySmall, + ) + NumberField("Keep at most (runs)", maxRuns) { + maxRuns = it; settings.maxRuns = it.toIntOrNull() ?: 0 + } + NumberField("Delete older than (days)", maxAgeDays) { + maxAgeDays = it; settings.maxAgeDays = it.toIntOrNull() ?: 0 + } + NumberField("Keep at most (MB)", maxTotalMb) { + maxTotalMb = it; settings.maxTotalMb = it.toIntOrNull() ?: 0 + } + Text( + "$archivedRuns run(s), ${archivedBytes / 1024} kB stored", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onApplyRetention) { Text("Apply now") } + TextButton(onClick = onDeleteAll) { Text("Delete all runs") } + } + } + } + + // ---- privacy ---- + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("What leaves the device", style = MaterialTheme.typography.titleMedium) + Text( + "Applied to uploads only. Measurements, verdicts and finding codes survive " + + "every level — only the parts that identify you or your network change.", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (level in PrivacyLevel.entries) { + FilterChip( + selected = privacy == level, + onClick = { privacy = level; settings.privacyLevel = level }, + label = { Text(level.wire) }, + ) + } + } + Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall) + + Toggle( + label = "Stable pseudonyms across runs", + detail = "Lets you compare uploaded runs over time (same SSID reads the same " + + "each time). It also links your uploads together, so leave it off on a " + + "server you don't run yourself.", + checked = stableSalt, + ) { stableSalt = it; settings.stableSalt = it } + + TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") } + } + } + + // ---- upload ---- + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Upload", style = MaterialTheme.typography.titleMedium) + Toggle( + label = "Upload finished runs automatically", + detail = "Sends each completed run to the server below, anonymized to the " + + "level above. The server may require more anonymization than you chose; " + + "it can never require less.", + checked = autoUpload, + ) { autoUpload = it; settings.autoUpload = it } + + OutlinedTextField( + value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it }, + label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it }, + label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = serverCred, + onValueChange = { serverCred = it; settings.serverCredential = it }, + label = { Text("Device credential") }, singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + Text( + if (settings.serverConfigured) "Server configured." + else "Uploads stay off until all three fields are set.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + Spacer(Modifier.height(24.dp)) + } +} + +private fun privacyExplanation(level: PrivacyLevel): String = when (level) { + PrivacyLevel.FULL -> + "Nothing is removed: SSIDs, MAC addresses, hostnames and discovered neighbours are sent " + + "as measured. Appropriate for a server you run yourself." + PrivacyLevel.BALANCED -> + "Network names and hostnames become pseudonyms, MAC addresses keep only their vendor " + + "prefix, public IP addresses keep only their /16, and discovered neighbours (SSDP, " + + "ARP, nearby networks) are dropped entirely. Private addresses stay readable, since " + + "192.168.1.1 describes the topology and not the person." + PrivacyLevel.STRICT -> + "Only numbers: test results, metrics and finding codes. No network description, no raw " + + "evidence, no finding text. Nothing left can identify a network." +} + +@Composable +private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(detail, style = MaterialTheme.typography.bodySmall) + } + Switch(checked = checked, onCheckedChange = onChange) + } +} + +@Composable +private fun NumberField(label: String, value: String, onChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = { s -> onChange(s.filter { it.isDigit() }.take(7)) }, + label = { Text(label) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/echolot-app/core-archive/build.gradle.kts b/echolot-app/core-archive/build.gradle.kts new file mode 100644 index 0000000..631d356 --- /dev/null +++ b/echolot-app/core-archive/build.gradle.kts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +// The on-device run archive: measurement documents on disk, with retention. +// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules +// — the part with edge cases — are unit-testable without a device. +dependencies { + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +kotlin { + 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() } diff --git a/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt b/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt new file mode 100644 index 0000000..3ca15af --- /dev/null +++ b/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package archive keeps completed measurement runs on the device. +// +// The point of an archive is the second run: "this network was fine on Tuesday" is only +// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is +// its own privacy problem, so retention is a first-class part of the type rather than a cleanup +// job somebody remembers to write — every save enforces it. +// +// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here +// needs a database, and a plain directory is something a user can inspect, copy off, or delete +// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid- +// write never leaves a half-document that reads as real. +package app.echo_lot.archive + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** Index entry for one archived run — enough for a history list without opening the documents. */ +@Serializable +data class ArchivedRun( + val id: String, + @SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long, + @SerialName("started_at") val startedAt: String? = null, + val verdict: String? = null, + @SerialName("finding_count") val findingCount: Int = 0, + @SerialName("size_bytes") val sizeBytes: Long = 0, + val anonymization: String = "full", + /** Whether this run has been accepted by a server, so history can show what is backed up. */ + val uploaded: Boolean = false, + @SerialName("uploaded_to") val uploadedTo: String? = null, +) + +/** + * Retention limits. All three are independent ceilings; a run is dropped when it violates any of + * them. Zero disables that limit. + * + * The default keeps a hundred runs or three months, whichever comes first. That is enough to see + * a pattern ("it degrades every evening") without turning the phone into an archive of every + * network its owner ever walked past. + */ +@Serializable +data class RetentionPolicy( + /** + * Whether to archive at all. Separate from the limits because "no limits" (every limit zero) + * and "keep nothing" are opposite intentions, and collapsing them onto the same value is how + * a user who turns all the caps off ends up with an empty history. + */ + val enabled: Boolean = true, + @SerialName("max_runs") val maxRuns: Int = 100, + @SerialName("max_age_days") val maxAgeDays: Int = 90, + @SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024, +) { + companion object { + /** Archiving off: runs are shown once and never written. */ + val KeepNothing = RetentionPolicy(enabled = false) + + /** Archiving on with no ceilings. Every run is kept until the user deletes it. */ + val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0) + + val Default = RetentionPolicy() + } + + val keepsAnything: Boolean get() = enabled +} + +/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */ +data class PurgeResult(val removed: List, val freedBytes: Long) { + val isEmpty: Boolean get() = removed.isEmpty() +} + +class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + init { + dir.mkdirs() + } + + /** + * Writes one run and applies retention. Returns the index entry, or null when the policy + * keeps nothing at all — in which case nothing is written, rather than written and instantly + * deleted (the difference matters on flash storage and to anyone watching the filesystem). + */ + fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? { + if (!policy.keepsAnything) return null + val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null + val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null + + writeAtomically(File(dir, meta.id + EXT), runJson) + writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta)) + purge(policy) + return meta + } + + /** History, newest first. */ + fun list(): List = + (dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray()) + .mapNotNull { f -> + runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull() + } + .sortedByDescending { it.savedAtEpochMs } + + fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText() + + fun delete(id: String): Boolean { + val s = safe(id) + val doc = File(dir, s + EXT).delete() + File(dir, s + META_EXT).delete() + return doc + } + + fun deleteAll(): Int = list().count { delete(it.id) } + + /** Records that a server accepted this run, so history can distinguish backed-up from local. */ + fun markUploaded(id: String, serverName: String) { + val f = File(dir, safe(id) + META_EXT) + val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull() + ?: return + writeAtomically( + f, + json.encodeToString( + ArchivedRun.serializer(), + meta.copy(uploaded = true, uploadedTo = serverName), + ), + ) + } + + fun totalBytes(): Long = list().sumOf { it.sizeBytes } + + /** + * Enforces the policy. Age first, then total size, then count: dropping stale runs may already + * satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it + * should not be pre-empted by a size sweep deleting last week instead. + */ + fun purge(policy: RetentionPolicy): PurgeResult { + val removed = ArrayList() + var freed = 0L + fun drop(r: ArchivedRun) { + if (delete(r.id)) { + removed.add(r.id) + freed += r.sizeBytes + } + } + + var kept = list() + if (policy.maxAgeDays > 0) { + val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000 + val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff } + stale.forEach(::drop) + kept = fresh + } + if (policy.maxTotalBytes > 0) { + var total = kept.sumOf { it.sizeBytes } + // Oldest first until we are under the ceiling. + for (r in kept.reversed()) { + if (total <= policy.maxTotalBytes) break + drop(r) + total -= r.sizeBytes + } + kept = kept.filter { it.id !in removed } + } + if (policy.maxRuns > 0 && kept.size > policy.maxRuns) { + kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first + } + return PurgeResult(removed, freed) + } + + // ---- internals --------------------------------------------------------------------- + + private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? { + val run = doc["run"]?.jsonObject ?: return null + val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null + return ArchivedRun( + id = id, + savedAtEpochMs = now(), + startedAt = run["started_at"]?.jsonPrimitive?.content, + verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content, + findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0, + sizeBytes = size, + anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full", + ) + } + + private fun writeAtomically(target: File, content: String) { + val tmp = File(target.parentFile, target.name + ".tmp") + tmp.writeText(content) + if (!tmp.renameTo(target)) { + target.delete() + tmp.renameTo(target) + } + } + + /** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */ + private fun safe(id: String): String = buildString { + for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c) + }.take(64) + + private companion object { + const val EXT = ".json" + const val META_EXT = ".meta.json" + } +} diff --git a/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt b/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt new file mode 100644 index 0000000..84a672b --- /dev/null +++ b/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.archive + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RunArchiveTest { + + private val dir: File = Files.createTempDirectory("echolot-archive").toFile() + private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time + + private fun archive() = RunArchive(dir) { clock } + + @AfterTest fun cleanup() { dir.deleteRecursively() } + + private fun doc(id: String, findings: Int = 1, pad: Int = 0): String { + val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" } + return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" + + """"findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}""" + } + + @Test + fun savedRunsComeBackNewestFirst() { + val a = archive() + for (i in 1..3) { + a.save(doc("run-$i")) + clock += 60_000 + } + assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id }) + } + + @Test + fun theIndexSummarisesTheDocument() { + val meta = assertNotNull(archive().save(doc("run-1", findings = 4))) + assertEquals("warn", meta.verdict) + assertEquals(4, meta.findingCount) + assertEquals("balanced", meta.anonymization) + assertEquals("2026-08-01T10:00:00Z", meta.startedAt) + assertFalse(meta.uploaded) + } + + @Test + fun theDocumentComesBackByteForByte() { + val a = archive() + val original = doc("run-1") + a.save(original) + assertEquals(original, a.read("run-1")) + } + + @Test + fun countLimitKeepsTheNewest() { + val a = archive() + val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0) + for (i in 1..7) { + a.save(doc("run-$i"), policy) + clock += 60_000 + } + assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id }) + assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry") + } + + @Test + fun ageLimitDropsRunsPastTheWindow() { + val a = archive() + val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0) + a.save(doc("old"), policy) + clock += 30L * 24 * 60 * 60 * 1000 // a month later + a.save(doc("new"), policy) + assertEquals(listOf("new"), a.list().map { it.id }) + } + + @Test + fun sizeLimitDropsOldestUntilUnderTheCeiling() { + val a = archive() + val one = doc("x", pad = 900).toByteArray().size.toLong() + val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10) + for (i in 1..5) { + a.save(doc("run-$i", pad = 900), policy) + clock += 60_000 + } + val kept = a.list() + assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}") + assertEquals("run-5", kept.first().id, "the newest run must always survive") + assertTrue(a.totalBytes() <= policy.maxTotalBytes) + } + + // A policy that keeps nothing must not write-then-delete: the run should never touch storage. + @Test + fun keepNothingWritesNothing() { + val a = archive() + assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing)) + assertTrue(a.list().isEmpty()) + assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy") + } + + // "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at + // one: turning every limit off should keep everything, not wipe the history. + @Test + fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() { + val a = archive() + for (i in 1..5) { + a.save(doc("run-$i"), RetentionPolicy.Unlimited) + clock += 60_000 + } + assertEquals(5, a.list().size) + assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing)) + assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived") + } + + @Test + fun uploadStateIsRecorded() { + val a = archive() + a.save(doc("run-1")) + a.markUploaded("run-1", "fmr") + val meta = a.list().single() + assertTrue(meta.uploaded) + assertEquals("fmr", meta.uploadedTo) + assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry") + } + + @Test + fun deleteRemovesBothFiles() { + val a = archive() + a.save(doc("run-1")) + assertTrue(a.delete("run-1")) + assertTrue(a.list().isEmpty()) + assertNull(a.read("run-1")) + assertEquals(0, dir.listFiles()?.size ?: 0) + } + + @Test + fun malformedInputIsRejectedRatherThanArchived() { + val a = archive() + assertNull(a.save("not json")) + assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity") + assertTrue(a.list().isEmpty()) + } + + // Run ids come from a document that may have been produced elsewhere; they must not be able + // to write outside the archive directory. + @Test + fun runIdsCannotEscapeTheArchiveDirectory() { + val a = archive() + a.save(doc("../../evil")) + val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray() + assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}") + } + + @Test + fun purgeReportsWhatItRemoved() { + val a = archive() + for (i in 1..5) { + a.save(doc("run-$i"), RetentionPolicy.Unlimited) + clock += 60_000 + } + val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0)) + assertEquals(3, result.removed.size) + assertTrue(result.freedBytes > 0) + assertEquals(2, a.list().size) + } +} diff --git a/echolot-app/core-engine/build.gradle.kts b/echolot-app/core-engine/build.gradle.kts index 3f2c4c6..4491550 100644 --- a/echolot-app/core-engine/build.gradle.kts +++ b/echolot-app/core-engine/build.gradle.kts @@ -12,6 +12,7 @@ plugins { dependencies { implementation(project(":core-protocol")) implementation(project(":core-measurement")) + implementation(project(":core-privacy")) implementation(libs.kotlinx.serialization.json) testImplementation(kotlin("test")) } diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveGrantedTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveGrantedTest.kt index f8327be..bcecc47 100644 --- a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveGrantedTest.kt +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveGrantedTest.kt @@ -54,19 +54,35 @@ class LiveGrantedTest { "sizes=${down.map { it.sizeBytes }.distinct()}") assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken") - // --- big_send: which downstream sizes survive? --- + // --- big_send with DF: the largest size that arrives is the downstream path MTU --- val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000) - val bsResp = control.action( + val dfResp = control.action( cred, session.sessionId, - """{"action":"big_send","sizes_bytes":${sizes}}""", + """{"action":"big_send","df":true,"sizes_bytes":${sizes}}""", ) - println("big_send accepted: ${bsResp.take(160)}") - val big = ps.collectGranted(windowMs = 4000) - .filter { it.type == Wire.TYPE_BIG_SEND } - val arrived = big.map { it.sizeBytes }.sorted() - println("big_send arrived sizes: $arrived (requested $sizes)") - assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived") - println("largest downstream datagram delivered: ${arrived.maxOrNull()}") + println("big_send(df) accepted: ${dfResp.take(200)}") + val dfArrived = ps.collectGranted(windowMs = 4000) + .filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted() + println("big_send(df) arrived: $dfArrived") + assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived") + val pathMtu = dfArrived.max() + + // --- and without DF, to see whether fragments get through above that --- + val fragResp = control.action( + cred, session.sessionId, + """{"action":"big_send","df":false,"sizes_bytes":${sizes}}""", + ) + println("big_send(frag) accepted: ${fragResp.take(200)}") + val fragArrived = ps.collectGranted(windowMs = 4000) + .filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted() + println("big_send(frag) arrived: $fragArrived") + + // The distinction the DF flag exists for: fragmented delivery may exceed the + // unfragmented path MTU, and reporting the former as the latter would be a lie. + println("downstream path MTU (payload bytes) = $pathMtu; " + + "largest fragmented delivery = ${fragArrived.maxOrNull()}") + assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu, + "fragmented delivery should reach at least as far as unfragmented") } control.deleteSession(cred, session.sessionId) } diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveUploadTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveUploadTest.kt new file mode 100644 index 0000000..12664ff --- /dev/null +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveUploadTest.kt @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.engine + +import app.echo_lot.privacy.Anonymizer +import app.echo_lot.privacy.PrivacyLevel +import app.echo_lot.privacy.Salt +import app.echo_lot.protocol.ControlClient +import app.echo_lot.protocol.UploadRefused +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete. + * + * The point is not that the HTTP works — it is that what comes *back off the server* has been + * stripped. Uploading and then re-reading the stored document is the only check that proves the + * anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without + * ECHOLOT_LIVE_*. + */ +class LiveUploadTest { + + 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 json = Json { prettyPrint = false } + + private fun sampleRun(id: String) = """ + { + "schema": "echolot/measurement", + "run": { + "id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z", + "notes": "kitchen table", + "device": {"manufacturer": "OnePlus", "model": "CPH2747"} + }, + "networks": [{ + "id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc", + "gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77", + "ssdp_responders": [{"friendly_name": "Living Room TV"}] + }], + "tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok", + "metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}], + "findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}], + "summary": {"verdict": "warn"} + } + """.trimIndent() + + @Test + fun uploadRoundTrip() { + if (url == null || pin == null || cred == null) { + println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return + } + val control = ControlClient(url, setOf(pin)) + val profile = control.profile(cred) + val policy = profile.uploads + println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " + + "max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}") + + val runId = "livetest-" + System.nanoTime().toString().takeLast(10) + val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization)) + val redacted = json.encodeToString( + JsonObject.serializer(), + Anonymizer(level, Salt.perRun(ByteArray(32) { 9 })) + .anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject), + ) + assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload") + + if (!policy.accepted) { + // A server configured to refuse must refuse — that is the behaviour worth asserting. + try { + control.uploadRun(cred, redacted) + throw AssertionError("server advertises mode=${policy.mode} but accepted an upload") + } catch (e: UploadRefused) { + println("upload correctly refused: ${e.message?.take(140)}") + return + } + } + + val created = control.uploadRun(cred, redacted) + println("stored: ${created.take(200)}") + + val listed = control.listRuns(cred) + assertTrue(listed.contains(runId), "uploaded run is missing from the server's list") + + val fetched = control.getRun(cred, runId) + assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server") + assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server") + assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server") + assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point") + assertTrue(fetched.contains("12.4"), "metrics should survive anonymization") + println("round trip verified: identifiers stripped, measurements intact") + + control.deleteRun(cred, runId) + assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run") + println("deleted") + } +} diff --git a/echolot-app/core-privacy/build.gradle.kts b/echolot-app/core-privacy/build.gradle.kts new file mode 100644 index 0000000..9a5af66 --- /dev/null +++ b/echolot-app/core-privacy/build.gradle.kts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately +// dependency-free beyond JSON: it must be trivially auditable, because a bug +// here leaks a user's network onto someone else's server. +dependencies { + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +kotlin { + 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() } diff --git a/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt new file mode 100644 index 0000000..5c3ae89 --- /dev/null +++ b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Anonymizer.kt @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package privacy implements the anonymization contract of measurement-schema.md §8. +// +// The threat model is specific. An engineer running their own server wants the full document — +// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's +// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a +// filter: the output is still a valid measurement document with the same tests, metrics and +// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is +// still answerable from pseudonyms alone. +// +// Two properties are load-bearing and are what the tests pin: +// - Consistency within a document: one input value always maps to one pseudonym, so +// correlations inside a run survive. +// - No consistency *across* documents unless the user asks for it: the salt is per-run by +// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is +// opt-in (`Salt.stable`) for people diffing their own history on their own server. +package app.echo_lot.privacy + +import kotlinx.serialization.json.* +import java.security.MessageDigest +import java.util.Locale + +/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */ +enum class PrivacyLevel(val wire: String) { + /** Nothing removed. The right choice for your own server. */ + FULL("full"), + + /** + * Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive: + * you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not + * which MikroTik, on which SSID, next to whose Chromecast. + */ + BALANCED("balanced"), + + /** + * Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their + * codes and severities but lose descriptions (which quote real names). What is left cannot + * identify a network, and is still enough for aggregate "how common is this fault" work. + */ + STRICT("strict"); + + companion object { + fun fromWire(s: String?): PrivacyLevel = + entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL + + /** The stricter of two levels — used to honour a server's minimum. */ + fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b + } +} + +/** + * The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded + * twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device. + * A stable salt trades that away for cross-run diffing and is only appropriate on a server you + * own — the app makes that an explicit choice, not a default. + */ +class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) { + companion object { + fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false) + fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true) + } +} + +/** + * Transforms a measurement document to [level]. + * + * Field classification is by JSON key name, because the schema names things consistently + * (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by + * reading one table. Anything unrecognized is treated as identifying when it is a string inside + * a known-sensitive container, and left alone otherwise — see [Classification]. + */ +class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) { + + private val cache = HashMap() + + fun anonymize(doc: JsonObject): JsonObject { + if (level == PrivacyLevel.FULL) return stamp(doc) + val walked = walkObject(doc, path = emptyList()) + val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked + return stamp(out) + } + + /** Records what was done, so a reader of the archived/uploaded document is never guessing. */ + private fun stamp(doc: JsonObject): JsonObject { + val run = doc["run"]?.jsonObject ?: return doc + val privacy = buildJsonObject { + put("anonymization", level.wire) + put("salt", if (salt.stable) "stable" else "per_run") + } + return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy)))) + } + + // ---- the tree walk ------------------------------------------------------------------- + + private fun walkObject(obj: JsonObject, path: List): JsonObject = buildJsonObject { + for ((k, v) in obj) { + val childPath = path + k + when { + Classification.dropAtBalanced(childPath) -> Unit // omit entirely + else -> put(k, walk(k, v, childPath)) + } + } + } + + private fun walk(key: String, v: JsonElement, path: List): JsonElement = when (v) { + is JsonObject -> walkObject(v, path) + is JsonArray -> JsonArray(v.map { walk(key, it, path) }) + is JsonPrimitive -> + if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive) + else v + } + + private fun transform(type: LogicalType?, value: String): String = when (type) { + null -> value + LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) } + LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value) + LogicalType.IP4 -> ip4(value) + LogicalType.IP6 -> ip6(value) + LogicalType.FQDN -> fqdn(value) + LogicalType.OPAQUE_ID -> "redacted" + LogicalType.FREETEXT -> "[removed: may contain identifying text]" + } + + // ---- per-type transforms ------------------------------------------------------------- + + /** + * Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the + * diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT + * MikroTik" does not. + */ + private fun macPreservingOui(value: String): String { + val sep = if (value.contains('-')) '-' else ':' + val parts = value.split(sep) + if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) } + val nic = pseudo("mac", value) { it } + return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6))) + .joinToString(sep.toString()) + .lowercase(Locale.ROOT) + } + + /** + * Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and + * CGNAT addresses say something about the topology and nothing about the person, and a run + * where 192.168.1.1 became a random public address would be actively misleading to read. + * Public addresses keep only their /16 so the network is still locatable at ISP granularity. + */ + private fun ip4(value: String): String { + val o = value.split(".") + if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value + val n = o.map { it.toInt() } + val reserved = n[0] == 10 || + (n[0] == 172 && n[1] in 16..31) || + (n[0] == 192 && n[1] == 168) || + (n[0] == 169 && n[1] == 254) || + (n[0] == 100 && n[1] in 64..127) || + n[0] == 127 || n[0] == 0 || n[0] >= 224 + if (reserved) return value + val h = pseudo("ip4", value) { it } + return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}" + } + + /** + * IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in + * the same allocation) and pseudonymizes the rest — the interface identifier is the part that + * is a device fingerprint, especially with EUI-64. + */ + private fun ip6(value: String): String { + val v = value.lowercase(Locale.ROOT) + if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v + val groups = v.substringBefore('%').split(":") + if (groups.size < 3) return v + val h = pseudo("ip6", value) { it } + return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}" + } + + /** + * Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local" + * or "…under example.com" survives without naming the host. The suffix list is deliberately + * short: guessing wrong keeps *more* pseudonymized, never less. + */ + private fun fqdn(value: String): String { + if (value.isEmpty()) return value + val trailing = value.endsWith(".") + val labels = value.trimEnd('.').split(".") + if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) } + val keep = if (labels.last() in publicSuffixes) 1 else 0 + val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } } + return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else "" + } + + // ---- STRICT --------------------------------------------------------------------------- + + /** + * STRICT keeps the shape of the document and the numbers, and nothing that quotes the + * network back. Evidence goes (trains carry addresses and hostnames), finding prose goes + * (it interpolates real names), networks go entirely. + */ + private fun strip(doc: JsonObject): JsonObject = buildJsonObject { + for ((k, v) in doc) { + when (k) { + "networks", "server_sessions" -> Unit + "tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t -> + val o = t.jsonObject + JsonObject(o.filterKeys { it != "evidence" && it != "params" }) + })) + "findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f -> + val o = f.jsonObject + JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" }) + })) + "run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" })) + else -> put(k, v) + } + } + } + + // ---- pseudonym machinery --------------------------------------------------------------- + + /** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */ + private fun pseudo(domain: String, value: String, shape: (String) -> String): String = + cache.getOrPut("$domain$value") { + val md = MessageDigest.getInstance("SHA-256") + md.update(salt.bytes) + md.update(domain.toByteArray()) + md.update(0) + md.update(value.lowercase(Locale.ROOT).toByteArray()) + shape(md.digest().joinToString("") { "%02x".format(it) }) + } + + private companion object { + val publicSuffixes = setOf( + "local", "lan", "home", "internal", "arpa", + "com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk", + ) + } +} diff --git a/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt new file mode 100644 index 0000000..155de4c --- /dev/null +++ b/echolot-app/core-privacy/src/main/kotlin/app/echo_lot/privacy/Classification.kt @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.privacy + +/** The logical types of measurement-schema.md §8. */ +enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT } + +/** + * Which fields hold which logical type, and which whole subtrees are dropped below FULL. + * + * This is a table on purpose. The alternative — annotating the Kotlin models and reflecting over + * them — spreads the answer across every module and makes "what exactly gets uploaded?" a + * question you answer by reading the whole app. Here it is one file a reviewer can check against + * the spec in a sitting, and a new field that nobody classified stays visible in the output + * rather than being silently mangled. + * + * The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports; + * an SSID wrongly kept is a leak nobody notices. + */ +object Classification { + + private val byKey: Map = buildMap { + listOf( + "ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4", + "observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4", + ).forEach { put(it, LogicalType.IP4) } + + listOf( + "ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6", + "observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6", + "link_local", "ra_source", "prefix", + ).forEach { put(it, LogicalType.IP6) } + + listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac") + .forEach { put(it, LogicalType.MAC) } + listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) } + listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) } + + listOf( + "fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name", + "friendly_name", "server_name", "sni", "cname", "search_domain", "device_name", + ).forEach { put(it, LogicalType.FQDN) } + + listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid") + .forEach { put(it, LogicalType.OPAQUE_ID) } + + listOf("notes", "detail", "raw", "excerpt", "location", "model_description") + .forEach { put(it, LogicalType.FREETEXT) } + } + + /** + * Whole subtrees that BALANCED removes rather than pseudonymizes. + * + * Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest + * case: they describe other people's devices, they are a household fingerprint even with the + * names hashed, and no metric depends on them. Dropping beats mangling. + */ + private val droppedPaths: List> = listOf( + listOf("networks", "neighbors"), + listOf("networks", "arp"), + listOf("networks", "wifi", "scan_results"), + listOf("run", "device", "security_patch"), + ) + + /** Key suffixes whose whole value is a neighbour inventory wherever they appear. */ + private val droppedKeys = setOf( + "ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results", + "nearby_networks", "peers", "raw_dump", "dumpsys", + ) + + fun typeOf(key: String, path: List): LogicalType? { + byKey[key]?.let { return it } + // Inside a discovery/neighbour container every string is someone's device name until + // proven otherwise, so classify unknown strings there as free text rather than passing + // them through. + if (path.any { it in droppedKeys }) return LogicalType.FREETEXT + return null + } + + fun dropAtBalanced(path: List): Boolean { + if (path.isNotEmpty() && path.last() in droppedKeys) return true + return droppedPaths.any { dropped -> dropped.all { path.contains(it) } } + } +} diff --git a/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt b/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt new file mode 100644 index 0000000..8456df3 --- /dev/null +++ b/echolot-app/core-privacy/src/test/kotlin/app/echo_lot/privacy/AnonymizerTest.kt @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.privacy + +import kotlinx.serialization.json.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * These tests are the audit of the anonymizer: each one states a property someone's privacy + * depends on, so a regression here fails loudly rather than quietly leaking. + */ +class AnonymizerTest { + + private val json = Json { prettyPrint = false } + private val salt = Salt.perRun(ByteArray(32) { it.toByte() }) + + private fun sample(): JsonObject = json.parseToJsonElement( + """ + { + "schema": "echolot/measurement", + "run": { + "id": "0190-run", "trigger": "manual", "notes": "at Anna's flat", + "device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"} + }, + "networks": [{ + "id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc", + "gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150", + "gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc", + "neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}], + "ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}] + }], + "tests": [{ + "id": "t1", "type": "train.udp_updown", "status": "ok", + "metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}, + "evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]} + }], + "findings": [{ + "id": "f1", "code": "nat.udp_rebinding", "severity": "medium", + "title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000" + }], + "summary": {"verdict": "warn"} + } + """.trimIndent(), + ).jsonObject + + private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc) + + private fun flat(e: JsonElement): String = e.toString() + + @Test + fun fullLeavesTheDocumentAloneButRecordsThat() { + val out = anon(PrivacyLevel.FULL) + assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content) + assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content) + } + + @Test + fun balancedRemovesTheSsidAndTheNotes() { + val text = flat(anon(PrivacyLevel.BALANCED)) + assertFalse(text.contains("Rambossek"), "SSID survived: $text") + assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text") + } + + @Test + fun balancedDropsNeighbourInventoriesEntirely() { + val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject + assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized") + assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized") + } + + @Test + fun balancedKeepsTheVendorHalfOfAMac() { + val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content + assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid") + assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid") + } + + @Test + fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() { + val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject + assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content, + "RFC1918 says nothing about the user and everything about the topology") + assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content) + assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."), + "the /16 should survive for ISP-level context") + } + + @Test + fun linkLocalIsKeptButGlobalV6IsNot() { + val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject + assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content) + assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content) + } + + @Test + fun metricsAndVerdictsAreNeverTouched() { + for (level in PrivacyLevel.entries) { + val out = anon(level) + val t = out["tests"]!!.jsonArray[0].jsonObject + assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9, + "$level changed a metric") + assertEquals("ok", t["status"]!!.jsonPrimitive.content) + assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content) + } + } + + @Test + fun findingCodesSurviveEveryLevelSoAggregationStillWorks() { + for (level in PrivacyLevel.entries) { + val f = anon(level)["findings"]!!.jsonArray[0].jsonObject + assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code") + assertEquals("medium", f["severity"]!!.jsonPrimitive.content) + } + } + + @Test + fun strictDropsEvidenceAndProse() { + val out = anon(PrivacyLevel.STRICT) + assertNull(out["networks"], "STRICT should not describe the network at all") + assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"]) + assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"]) + assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose") + } + + @Test + fun pseudonymsAreConsistentWithinADocument() { + val doc = json.parseToJsonElement( + """{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}""" + ).jsonObject + val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray + val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content + val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content + val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content + assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run") + assertNotEquals(a, c, "different SSIDs must not collide") + } + + @Test + fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() { + val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject + val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc) + val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc) + assertNotEquals( + one["networks"]!!.jsonArray[0].jsonObject["ssid"], + two["networks"]!!.jsonArray[0].jsonObject["ssid"], + "a per-run salt must not produce a cross-run tracking identifier", + ) + } + + @Test + fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() { + val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject + val secret = ByteArray(32) { 7 } + val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc) + val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc) + assertEquals( + one["networks"]!!.jsonArray[0].jsonObject["ssid"], + two["networks"]!!.jsonArray[0].jsonObject["ssid"], + ) + assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content) + } + + @Test + fun theDeclaredLevelMatchesWhatWasApplied() { + for (level in PrivacyLevel.entries) { + assertEquals( + level.wire, + anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content, + ) + } + } + + @Test + fun serverMinimumWins() { + assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT)) + assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL)) + assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense")) + } +} 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 index 2ff261d..1b3c1fc 100644 --- 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 @@ -17,6 +17,9 @@ import javax.net.ssl.HttpsURLConnection * @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) */ +/** The server declined to store this run, per the operator's policy. Not a transport failure. */ +class UploadRefused(message: String) : Exception(message) + class ControlClient(private val controlUrl: String, pins: Set) { private val json = Json { ignoreUnknownKeys = true } @@ -93,6 +96,44 @@ class ControlClient(private val controlUrl: String, pins: Set) { return body } + /** + * Uploads one measurement document. The body is sent exactly as given — whatever the + * anonymizer produced is what the server stores, so what the user was shown is what left + * the device. Returns the server's index entry as raw JSON. + * + * A refusal is not an error condition to retry: 403 means the operator's policy says no + * (uploads off, accounts required, or not anonymized enough), so it is surfaced as + * [UploadRefused] for the caller to show rather than swallow. + */ + fun uploadRun(credential: String, documentJson: String): String { + val conn = open("/v1/runs", "POST", credential) + writeJson(conn, documentJson) + val body = body(conn) + when (conn.responseCode) { + in 200..299 -> return body + 403 -> throw UploadRefused(body) + 413 -> throw UploadRefused("run is larger than this server accepts: $body") + else -> error("upload failed: ${conn.responseCode} $body") + } + } + + /** Lists this device's runs stored on the server. */ + fun listRuns(credential: String): String { + val conn = open("/v1/runs", "GET", credential) + check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" } + return body(conn) + } + + fun getRun(credential: String, runId: String): String { + val conn = open("/v1/runs/$runId", "GET", credential) + check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" } + return body(conn) + } + + fun deleteRun(credential: String, runId: String) { + open("/v1/runs/$runId", "DELETE", credential).responseCode + } + fun observations(credential: String, sessionId: String): String { val conn = open("/v1/sessions/$sessionId/observations", "GET", credential) check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" } 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 index 84b5b16..dd9d492 100644 --- 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 @@ -32,6 +32,30 @@ data class SelfTest( @SerialName("sysctl_ok") val sysctlOk: Boolean? = null, ) +/** + * The operator's upload rules, advertised in the profile so the app can present the choice + * honestly — greyed out with a reason when the server refuses, and pre-set to the server's + * minimum anonymization when it accepts — instead of discovering the policy by being rejected. + */ +@Serializable +data class UploadPolicy( + val mode: String = "off", + @SerialName("max_bytes") val maxBytes: Long = 0, + @SerialName("retention_days") val retentionDays: Int = 0, + @SerialName("max_runs_per_device") val maxRunsPerDevice: Int = 0, + @SerialName("min_anonymization") val minAnonymization: String = "full", + val reason: String? = null, +) { + val accepted: Boolean get() = mode == "anonymous" || mode == "account" + + /** Why uploads are unavailable, in words a user can act on. */ + fun refusalReason(): String? = when (mode) { + "off" -> reason ?: "This server does not accept uploaded runs." + "account" -> "This server only accepts uploads from signed-in accounts." + else -> null + } +} + @Serializable data class Profile( @SerialName("profile_version") val profileVersion: Int = 0, @@ -42,6 +66,7 @@ data class Profile( @SerialName("canary_zone") val canaryZone: String = "", @SerialName("server_selftest") val serverSelftest: SelfTest? = null, val pins: List = emptyList(), + val uploads: UploadPolicy = UploadPolicy(), ) { fun supports(capability: String) = capability in capabilities } diff --git a/echolot-app/settings.gradle.kts b/echolot-app/settings.gradle.kts index 2685952..26e179d 100644 --- a/echolot-app/settings.gradle.kts +++ b/echolot-app/settings.gradle.kts @@ -24,6 +24,8 @@ rootProject.name = "echolot-app" include(":core-protocol") include(":core-measurement") include(":core-engine") +include(":core-privacy") +include(":core-archive") include(":core-probe") include(":core-shizuku") include(":app") diff --git a/server/internal/canarydns/canarydns.go b/server/internal/canarydns/canarydns.go index b2ab9ac..2a91e47 100644 --- a/server/internal/canarydns/canarydns.go +++ b/server/internal/canarydns/canarydns.go @@ -39,13 +39,13 @@ const ( // Query is one logged canary lookup (spec §6 dns_canary shape). type Query struct { - QName string `json:"qname"` - At time.Time `json:"at"` - ResolverIP string `json:"resolver_ip"` - Transport string `json:"transport"` // "udp" | "tcp" - EDNS edns `json:"edns"` - ECS string `json:"ecs,omitempty"` - CasePreserved bool `json:"case_preserved"` + QName string `json:"qname"` + At time.Time `json:"at"` + ResolverIP string `json:"resolver_ip"` + Transport string `json:"transport"` // "udp" | "tcp" + EDNS edns `json:"edns"` + ECS string `json:"ecs,omitempty"` + CasePreserved bool `json:"case_preserved"` // qname_minimized is not reliably detectable authoritative-side without // cross-query correlation; left false (TODO) rather than guessed. QNameMinimized bool `json:"qname_minimized"` @@ -59,8 +59,8 @@ type edns struct { // Server is the authoritative responder for one canary zone. type Server struct { - zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app." - nsName string // this server's own name for NS/authority answers + zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app." + nsName string // this server's own name for NS/authority answers primaryV4 netip.Addr primaryV6 netip.Addr @@ -284,4 +284,3 @@ func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte { } return hdr } - diff --git a/server/internal/canarydns/canarydns_test.go b/server/internal/canarydns/canarydns_test.go index 01844b3..97f5f1f 100644 --- a/server/internal/canarydns/canarydns_test.go +++ b/server/internal/canarydns/canarydns_test.go @@ -21,7 +21,7 @@ func buildQuery(name string, qtype uint16, ednsBufsize int) []byte { msg = binary.BigEndian.AppendUint16(msg, classIN) if ednsBufsize > 0 { binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT - msg = append(msg, 0) // root name + msg = append(msg, 0) // root name msg = binary.BigEndian.AppendUint16(msg, typeOPT) msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize)) msg = binary.BigEndian.AppendUint32(msg, 0) diff --git a/server/internal/control/httpecho_test.go b/server/internal/control/httpecho_test.go index cc6e601..2b721c2 100644 --- a/server/internal/control/httpecho_test.go +++ b/server/internal/control/httpecho_test.go @@ -60,4 +60,3 @@ func TestTLSReferenceReturnsChain(t *testing.T) { t.Fatalf("leaf DER not round-tripped: %x", first) } } - diff --git a/server/internal/dataplane/connfor_test.go b/server/internal/dataplane/connfor_test.go new file mode 100644 index 0000000..7032d70 --- /dev/null +++ b/server/internal/dataplane/connfor_test.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package dataplane + +import ( + "net" + "net/netip" + "testing" +) + +// A multi-homed server must answer from the address the client has been talking to. Sending from +// a sibling address is silently dropped by the client's NAT or stateful firewall, and the client +// then reports downstream loss that never happened — a wrong measurement, which is worse than a +// failed one. This was a real bug: fmr binds two IPv4 addresses, the granted train went out from +// the one the session had never used, and every packet vanished in transit. +func TestConnForPrefersTheAddressTheSessionUsed(t *testing.T) { + // Two loopback-bound sockets stand in for the two service addresses. + a := mustListen(t, "127.0.0.1:0") + b := mustListen(t, "127.0.0.1:0") + defer a.Close() + defer b.Close() + + srv := &Server{} + srv.conns = []*net.UDPConn{a, b} + + client := netip.MustParseAddrPort("198.51.100.7:41000") + bLocal := b.LocalAddr().(*net.UDPAddr).AddrPort() + + if got := srv.connFor(client, bLocal); got != b { + t.Fatalf("connFor picked the wrong socket: want the one the session used (%v)", bLocal) + } + + // With no recorded local address (nothing received yet) any socket of the right family is + // the best available answer — but it must still be one, not nil. + if got := srv.connFor(client, netip.AddrPort{}); got == nil { + t.Fatal("connFor returned nil when a family match exists") + } +} + +func TestConnForFallsBackByFamily(t *testing.T) { + v4 := mustListen(t, "127.0.0.1:0") + defer v4.Close() + srv := &Server{} + srv.conns = []*net.UDPConn{v4} + + // A recorded local address that no longer matches any bound socket (a reload changed the + // binds) must not strand the session: fall back rather than return nil. + stale := netip.MustParseAddrPort("203.0.113.1:8442") + if got := srv.connFor(netip.MustParseAddrPort("198.51.100.7:41000"), stale); got != v4 { + t.Fatal("connFor should fall back to a family match when the recorded socket is gone") + } + + // No IPv6 socket is bound, so an IPv6 target has no answer — nil, not an IPv4 socket. + if got := srv.connFor(netip.MustParseAddrPort("[2001:db8::1]:41000"), netip.AddrPort{}); got != nil { + t.Fatal("connFor returned an IPv4 socket for an IPv6 target") + } +} + +func mustListen(t *testing.T, addr string) *net.UDPConn { + t.Helper() + ua, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + t.Fatal(err) + } + c, err := net.ListenUDP("udp", ua) + if err != nil { + t.Fatal(err) + } + return c +} diff --git a/server/internal/dataplane/granted.go b/server/internal/dataplane/granted.go index c3567b5..203c05f 100644 --- a/server/internal/dataplane/granted.go +++ b/server/internal/dataplane/granted.go @@ -26,7 +26,7 @@ func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeB if !target.IsValid() { return 0, fmt.Errorf("no observed data-plane source") } - conn := s.connFor(target) + conn := s.connFor(target, sess.DataLocal()) if conn == nil { return 0, fmt.Errorf("no data-plane socket matches target family") } @@ -75,7 +75,7 @@ func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, d if !target.IsValid() { return nil, fmt.Errorf("no observed data-plane source") } - conn := s.connFor(target) + conn := s.connFor(target, sess.DataLocal()) if conn == nil { return nil, fmt.Errorf("no data-plane socket matches target family") } diff --git a/server/internal/dataplane/udp.go b/server/internal/dataplane/udp.go index d0c03be..2eba9c0 100644 --- a/server/internal/dataplane/udp.go +++ b/server/internal/dataplane/udp.go @@ -73,9 +73,23 @@ func (s *Server) Serve(conn *net.UDPConn) error { } // connFor picks a retained socket whose family matches the target. -func (s *Server) connFor(target netip.AddrPort) *net.UDPConn { +// connFor picks the socket to send to target from. +// +// When the session recorded which local address it has been talking to (local), that socket wins +// outright. Falling back to "any socket of the right family" is only correct for a single-homed +// server: on a multi-homed one it sends from a sibling address the client's NAT has no mapping +// for, the packets are dropped in transit, and the client reports downstream loss that does not +// exist. That bug is invisible in a lab with one address, which is exactly why this is explicit. +func (s *Server) connFor(target, local netip.AddrPort) *net.UDPConn { s.mu.Lock() defer s.mu.Unlock() + if local.IsValid() { + for _, c := range s.conns { + if c.LocalAddr().(*net.UDPAddr).AddrPort() == local { + return c + } + } + } want4 := target.Addr().Unmap().Is4() for _, c := range s.conns { la := c.LocalAddr().(*net.UDPAddr).AddrPort() @@ -94,7 +108,7 @@ func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error { if !target.IsValid() { return fmt.Errorf("session has no observed data-plane source yet") } - conn := s.connFor(target) + conn := s.connFor(target, sess.DataLocal()) if conn == nil { return fmt.Errorf("no data-plane socket matches target family") } @@ -131,6 +145,9 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx return } sess.NoteDataSource(raddr) + if la, ok := conn.LocalAddr().(*net.UDPAddr); ok { + sess.NoteDataLocal(la.AddrPort()) + } sess.RecordUDP(session.UDPObservation{ Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(), Src: raddr.String(), Size: len(pkt), Type: typ, diff --git a/server/internal/selftest/selftest.go b/server/internal/selftest/selftest.go index 7e2736e..7c3b0f1 100644 --- a/server/internal/selftest/selftest.go +++ b/server/internal/selftest/selftest.go @@ -33,10 +33,10 @@ type Check struct { // MTUResult is one egress path-MTU probe outcome. type MTUResult struct { - Target string `json:"target"` - DiscoveredMTU int `json:"discovered_mtu"` - FullMTU bool `json:"full_mtu"` // >= 1500 - Err string `json:"err,omitempty"` + Target string `json:"target"` + DiscoveredMTU int `json:"discovered_mtu"` + FullMTU bool `json:"full_mtu"` // >= 1500 + Err string `json:"err,omitempty"` } // Report is the whole self-test. diff --git a/server/internal/session/session.go b/server/internal/session/session.go index b924b32..db1339b 100644 --- a/server/internal/session/session.go +++ b/server/internal/session/session.go @@ -27,6 +27,7 @@ type Session struct { // Last data-plane source seen with a valid HMAC (NAT rebinding evidence). mu sync.Mutex dataSource netip.AddrPort + dataLocal netip.AddrPort // Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus // a bitmask of the 1024 preceding. maxSeq uint32 @@ -192,6 +193,25 @@ func (s *Session) CheckSeq(seq uint32) bool { return true } +// DataLocal returns the server-side address that received this session's data-plane traffic. +// +// This matters more than it looks: a server bound to several addresses must send granted traffic +// back from the one the client has been talking to. Any stateful firewall or NAT in between has +// a mapping keyed on that exact pair, and a reply from a sibling address is dropped — which the +// client would then measure as downstream loss. See connFor. +func (s *Session) DataLocal() netip.AddrPort { + s.mu.Lock() + defer s.mu.Unlock() + return s.dataLocal +} + +// NoteDataLocal records which of our own bound addresses saw this session's traffic. +func (s *Session) NoteDataLocal(ap netip.AddrPort) { + s.mu.Lock() + defer s.mu.Unlock() + s.dataLocal = ap +} + // NoteDataSource records the latest verified data-plane source. func (s *Session) NoteDataSource(ap netip.AddrPort) { s.mu.Lock() diff --git a/server/internal/tcpecho/ja4_test.go b/server/internal/tcpecho/ja4_test.go index 9be2e48..862d40a 100644 --- a/server/internal/tcpecho/ja4_test.go +++ b/server/internal/tcpecho/ja4_test.go @@ -18,7 +18,7 @@ func buildClientHello(ciphers []uint16) []byte { var body []byte body = append(body, u16(0x0303)...) // client_version TLS1.2 body = append(body, make([]byte, 32)...) // random - body = append(body, 0) // session_id len 0 + body = append(body, 0) // session_id len 0 // cipher suites cs := []byte{} for _, c := range ciphers { @@ -36,8 +36,8 @@ func buildClientHello(ciphers []uint16) []byte { exts = append(exts, data...) } // SNI: server_name_list -> host_name "x" - sni := append(u16(3), 0) // list len 3, name_type host_name(0) - sni = append(sni, u16(1)...) // name len 1 + sni := append(u16(3), 0) // list len 3, name_type host_name(0) + sni = append(sni, u16(1)...) // name len 1 sni = append(sni, 'x') addExt(0x0000, sni) // ALPN: protocol_name_list -> "h2" diff --git a/server/internal/tcpecho/tcpecho_test.go b/server/internal/tcpecho/tcpecho_test.go index 9dfb8f3..30ae2f8 100644 --- a/server/internal/tcpecho/tcpecho_test.go +++ b/server/internal/tcpecho/tcpecho_test.go @@ -33,7 +33,7 @@ func TestPlainEchoServerSpeaksFirst(t *testing.T) { t.Fatalf("no greeting: %v", err) } var g struct { - TLS bool `json:"tls"` + TLS bool `json:"tls"` Src string `json:"observed_src"` } if err := json.Unmarshal(line, &g); err != nil {