From ad85f3bfcd27217e9c41db126fa57c99b2a393f3 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 12:06:22 +0200 Subject: [PATCH] =?UTF-8?q?enrollment:=20the=20server=20mints=20the=20?= =?UTF-8?q?=C2=A72.1=20bootstrap=20link,=20the=20app=20consumes=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/enroll-tokens now returns the whole link, not just the token: echolot://enroll?v=1&u=&p=pin-sha256:&t= The server is the only party that knows all three parts at once, and the part an operator gets wrong by hand is the base64 pin — which does not fail loudly, it just never matches, surfacing days later as an inscrutable TLS error. The app takes the link from a paste or from an echolot:// deep link (QR scan), and writes URL, pin and credential together or not at all. One trap the tests pin: an unencoded "+" in a query string decodes to a space, so a hand-assembled link arrives with a pin wrong by one character. Base64 has no spaces, so they are restored — unambiguous, and it cannot damage a correctly encoded pin. Also fixes a spec divergence: §2.1 names the field device_credential and the first implementation shipped "credential". Both are sent now and the client prefers the spec's; the alias goes once nothing reads it. Co-Authored-By: Claude Fable 5 --- .../.kotlin/errors/errors-1785578570586.log | 75 +++++++++++ echolot-app/app/src/main/AndroidManifest.xml | 12 ++ .../kotlin/app/echo_lot/app/MainActivity.kt | 12 ++ .../main/kotlin/app/echo_lot/app/RunStore.kt | 29 +++++ .../kotlin/app/echo_lot/app/RunViewModel.kt | 8 ++ .../kotlin/app/echo_lot/app/SettingsScreen.kt | 28 +++++ echolot-app/core-engine/build.gradle.kts | 2 +- .../app/echo_lot/engine/LiveEnrollmentTest.kt | 63 ++++++++++ .../app/echo_lot/protocol/Enrollment.kt | 119 ++++++++++++++++++ .../kotlin/app/echo_lot/protocol/Model.kt | 12 +- .../app/echo_lot/protocol/EnrollmentTest.kt | 101 +++++++++++++++ echolot-app/scripts/test-fmr.sh | 23 +++- server/cmd/echolot-server/main.go | 35 +++++- server/internal/config/config.go | 5 + server/internal/control/control.go | 26 +++- 15 files changed, 533 insertions(+), 17 deletions(-) create mode 100644 echolot-app/.kotlin/errors/errors-1785578570586.log create mode 100644 echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt create mode 100644 echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt create mode 100644 echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt diff --git a/echolot-app/.kotlin/errors/errors-1785578570586.log b/echolot-app/.kotlin/errors/errors-1785578570586.log new file mode 100644 index 0000000..b6e3a58 --- /dev/null +++ b/echolot-app/.kotlin/errors/errors-1785578570586.log @@ -0,0 +1,75 @@ +kotlin version: 2.2.10 +error message: Daemon compilation failed: null +java.lang.Exception + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69) + at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174) + at java.base/java.util.concurrent.FutureTask.run(Unknown Source) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:191) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$500(DefaultConditionalExecutionQueue.java:112) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:168) + at org.gradle.internal.Factories$1.create(Factories.java:30) + at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$runAndReleaseLocks$0(DefaultWorkerLeaseService.java:300) + at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:43) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAndReleaseLocks(DefaultWorkerLeaseService.java:298) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:294) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:286) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:130) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:135) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:163) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:125) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source) + at java.base/java.util.concurrent.FutureTask.run(Unknown Source) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) +Caused by: java.nio.file.NoSuchFileException: C:\Users\mram.AD\AppData\Local\Temp\kotlin-backups9735132086774701577\24.backup -> C:\Users\mram.AD\dev\echolot\echolot-app\core-protocol\build\classes\kotlin\main\META-INF\core-protocol.kotlin_module + at java.base/sun.nio.fs.WindowsException.translateToIOException(Unknown Source) + at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.WindowsFileCopy.move(Unknown Source) + at java.base/sun.nio.fs.WindowsFileSystemProvider.move(Unknown Source) + at java.base/java.nio.file.Files.move(Unknown Source) + at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.revertChanges(CompilationTransaction.kt:231) + at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.close(CompilationTransaction.kt:256) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:740) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:124) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:679) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:93) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1806) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + ... 3 more + + diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml index f1941bc..e29224f 100644 --- a/echolot-app/app/src/main/AndroidManifest.xml +++ b/echolot-app/app/src/main/AndroidManifest.xml @@ -27,6 +27,18 @@ + + + + + + + Unit, onPreviewUpload: () -> Unit, onCheckServer: () -> Unit, + onEnroll: (String) -> Unit, serverStatus: String?, onBack: () -> Unit, ) { @@ -59,6 +60,7 @@ fun SettingsScreen( var autoUpload by remember { mutableStateOf(settings.autoUpload) } var privacy by remember { mutableStateOf(settings.privacyLevel) } var stableSalt by remember { mutableStateOf(settings.stableSalt) } + var enrollLink by remember { mutableStateOf("") } var serverUrl by remember { mutableStateOf(settings.serverUrl) } var serverPin by remember { mutableStateOf(settings.serverPin) } var serverCred by remember { mutableStateOf(settings.serverCredential) } @@ -151,6 +153,32 @@ fun SettingsScreen( checked = autoUpload, ) { autoUpload = it; settings.autoUpload = it } + // Enrollment first, because it is the path that works: one link carries the + // URL, the pin and a single-use token. The three fields below exist for when + // someone has to reconstruct a configuration by hand, not as the normal route. + Text( + "Paste an enrollment link from your server operator, or scan its QR code. " + + "It fills in all three fields below. The link contains a one-time token — " + + "treat it like a password until it is used.", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = enrollLink, onValueChange = { enrollLink = it }, + label = { Text("echolot://enroll?…") }, singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + onEnroll(enrollLink) + enrollLink = "" // spent either way; leaving it around invites a retry + serverUrl = settings.serverUrl + serverPin = settings.serverPin + serverCred = settings.serverCredential + }, + enabled = enrollLink.isNotBlank(), + ) { Text("Enroll") } + OutlinedTextField( value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it }, label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), diff --git a/echolot-app/core-engine/build.gradle.kts b/echolot-app/core-engine/build.gradle.kts index 4491550..62ee61b 100644 --- a/echolot-app/core-engine/build.gradle.kts +++ b/echolot-app/core-engine/build.gradle.kts @@ -25,6 +25,6 @@ java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaV tasks.test { useJUnitPlatform() - listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET") + listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET","ECHOLOT_ENROLL_URI") .forEach { k -> System.getenv(k)?.let { environment(k, it) } } } diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt new file mode 100644 index 0000000..ff62767 --- /dev/null +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveEnrollmentTest.kt @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.engine + +import app.echo_lot.protocol.EnrollmentLink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Enrolls against a LIVE server using the link the server itself minted (probe-protocol.md §2.1). + * + * This is the test that matters for enrollment, because the failure mode it guards against is a + * *disagreement* between two programs: the Go side assembles the link, the Kotlin side takes it + * apart, and if they differ by one percent-encoding the pin is wrong by one character — which + * does not fail loudly, it fails as an inscrutable TLS error days later. A unit test on either + * side alone cannot see that. + * + * Needs ECHOLOT_ENROLL_URI (minted over SSH by scripts/test-fmr.sh); self-skips without it. + */ +class LiveEnrollmentTest { + + private val enrollUri = System.getenv("ECHOLOT_ENROLL_URI") + + @Test + fun enrollsFromTheServersOwnLink() { + if (enrollUri.isNullOrBlank()) { + println("LiveEnrollmentTest skipped (no ECHOLOT_ENROLL_URI)"); return + } + println("link: ${enrollUri.take(60)}…") + + val link = assertNotNull( + EnrollmentLink.parse(enrollUri), + "the client could not parse a link the server produced — the two sides disagree", + ) + println("parsed: url=${link.controlUrl} pin=${link.pin.take(12)}… token=${link.token.take(8)}…") + + // Redeeming applies the pin to the very request that spends the token, so a wrong pin + // fails here at the handshake rather than after the token is gone. + val enrolled = link.redeem(deviceName = "live-test", appVersion = "0.2.0") + assertTrue(enrolled.credential.isNotBlank(), "no credential came back") + assertTrue(enrolled.deviceId.isNotBlank(), "no device id came back") + println("enrolled: device=${enrolled.deviceId} server=${enrolled.profile.name} " + + "${enrolled.profile.serverVersion}") + + // The credential must actually work, and the pin from the link must be the one that + // verifies the server — that is the whole claim the link is making. + assertEquals(link.controlUrl, enrolled.controlUrl) + assertTrue(enrolled.profile.capabilities.contains("udp-probe"), + "profile fetched with the new credential looks wrong: ${enrolled.profile.capabilities}") + + // Single-use: a token that still works after redemption is a token an attacker can reuse. + try { + link.redeem(deviceName = "should-not-happen", appVersion = "0.2.0") + fail("the enrollment token was accepted twice — it must be single-use") + } catch (t: Throwable) { + println("second redemption correctly refused: ${t.message?.take(120)}") + } + } +} diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt new file mode 100644 index 0000000..c2a3ddb --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.protocol + +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * The enrollment bootstrap of probe-protocol.md §2.1. + * + * ``` + * echolot://enroll?v=1&u=&p=pin-sha256:&t= + * ``` + * + * One string carries everything a device needs to start trusting a server: where it is, which key + * to pin, and a single-use token proving the operator meant to admit this device. That is the + * whole point — it is why enrollment can be a paste or a QR scan rather than three fields typed + * from a screenshot, which is what people actually do wrong. + * + * **The link is a secret.** It contains a bearer token; anyone who sees it before the device does + * can enroll instead. Tokens are single-use and short-lived precisely so a leaked link is a + * bounded problem, but it should be treated like a password while it is live. + */ +data class EnrollmentLink( + /** e.g. "https://fmr-1.echo-lot.app:8443" */ + val controlUrl: String, + /** Base64 SPKI hash, without the "pin-sha256:" prefix — the form [ControlClient] wants. */ + val pin: String, + val token: String, +) { + /** Rebuilds the URI. Round-trips with [parse]; used for tests and for sharing a link on. */ + fun toUri(): String = buildString { + append("echolot://enroll?v=1") + append("&u=").append(enc(controlUrl)) + append("&p=").append(enc(PIN_PREFIX + pin)) + append("&t=").append(enc(token)) + } + + /** + * Redeems the token and returns a usable server configuration. + * + * The pin is applied to the very request that redeems the token, so a link pointing at an + * impostor fails at the TLS handshake rather than after handing it a token. That ordering is + * the reason the pin travels in the link at all. + */ + fun redeem(deviceName: String? = null, appVersion: String = ""): Enrolled { + val client = ControlClient(controlUrl, setOf(pin), appVersion) + val response = client.enroll(token, deviceName) + val profile = client.profile(response.credential) + return Enrolled( + controlUrl = controlUrl, + pin = pin, + credential = response.credential, + deviceId = response.deviceId, + profile = profile, + ) + } + + companion object { + const val SCHEME = "echolot" + const val HOST = "enroll" + private const val PIN_PREFIX = "pin-sha256:" + + /** + * Parses a bootstrap link. Returns null for anything that is not one — a malformed link + * must not be half-applied, because a half-configured server is a confusing failure much + * later rather than an obvious one now. + */ + fun parse(raw: String?): EnrollmentLink? { + val s = raw?.trim() ?: return null + val scheme = s.substringBefore("://", "") + if (!scheme.equals(SCHEME, ignoreCase = true)) return null + val rest = s.substringAfter("://") + val host = rest.substringBefore('?').trim('/') + if (!host.equals(HOST, ignoreCase = true)) return null + + val params = HashMap() + for (pair in rest.substringAfter('?', "").split('&')) { + if (pair.isEmpty()) continue + val k = pair.substringBefore('=') + val v = pair.substringAfter('=', "") + params[k] = dec(v) + } + + // v is the link format, not the protocol. Unknown versions are refused rather than + // guessed at: the fields could mean anything. + val version = params["v"] ?: "1" + if (version != "1") return null + + val url = params["u"]?.trim().orEmpty() + val pinRaw = params["p"]?.trim().orEmpty() + val token = params["t"]?.trim().orEmpty() + if (url.isEmpty() || pinRaw.isEmpty() || token.isEmpty()) return null + if (!url.startsWith("https://", ignoreCase = true)) return null + + // A "+" in a query string decodes to a space, so a link whose base64 pin was pasted + // in unencoded arrives with spaces where "+" belonged — and a pin that is wrong by + // one character does not fail loudly, it just never matches, which surfaces much + // later as an inexplicable TLS error. Base64 has no spaces, so putting them back is + // unambiguous and cannot damage a correctly-encoded pin. + val pin = pinRaw.removePrefix(PIN_PREFIX).replace(' ', '+') + if (pin.isEmpty()) return null + return EnrollmentLink(controlUrl = url.trimEnd('/'), pin = pin, token = token) + } + + private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") + private fun dec(s: String) = runCatching { URLDecoder.decode(s, "UTF-8") }.getOrDefault(s) + } +} + +/** A server this device is now enrolled with, ready to be stored in settings. */ +data class Enrolled( + val controlUrl: String, + val pin: String, + val credential: String, + val deviceId: String, + val profile: Profile, +) 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 256246a..2fb5ff0 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 @@ -13,8 +13,16 @@ import kotlinx.serialization.json.JsonElement @Serializable data class EnrollResponse( @SerialName("device_id") val deviceId: String, - val credential: String, -) + /** The spec's name (§2.1). */ + @SerialName("device_credential") val deviceCredential: String? = null, + /** What the first server implementation shipped. Read for older servers; do not emit. */ + @SerialName("credential") val legacyCredential: String? = null, +) { + /** Whichever field the server used. */ + val credential: String + get() = deviceCredential ?: legacyCredential + ?: error("enroll response carried no credential") +} @Serializable data class Target( diff --git a/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt new file mode 100644 index 0000000..cf19059 --- /dev/null +++ b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/EnrollmentTest.kt @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class EnrollmentTest { + + private val pin = "zRV9qkiLnRexAeh4RrSfJzbPWO+U/2Oj2/NVM/KfXlg=" + private val url = "https://fmr-1.echo-lot.app:8443" + private val token = "abc123-token_value" + + @Test + fun parsesTheSpecFormat() { + val link = assertNotNull( + EnrollmentLink.parse( + "echolot://enroll?v=1&u=https%3A%2F%2Ffmr-1.echo-lot.app%3A8443" + + "&p=pin-sha256%3AzRV9qkiLnRexAeh4RrSfJzbPWO%2BU%2F2Oj2%2FNVM%2FKfXlg%3D" + + "&t=abc123-token_value" + ) + ) + assertEquals(url, link.controlUrl) + assertEquals(pin, link.pin, "the pin-sha256: prefix should be stripped for ControlClient") + assertEquals(token, link.token) + } + + // The pin is base64: it contains +, / and = , every one of which means something else in a + // query string. Getting the decoding wrong yields a pin that silently never matches. + @Test + fun survivesBase64PunctuationThroughARoundTrip() { + val original = EnrollmentLink(url, pin, token) + val reparsed = assertNotNull(EnrollmentLink.parse(original.toUri())) + assertEquals(original, reparsed) + } + + @Test + fun acceptsAnUnprefixedPin() { + val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?v=1&u=$url&p=$pin&t=$token")) + assertEquals(pin, link.pin) + } + + // A hand-assembled link often has its base64 pin pasted in raw. "+" then decodes to a space + // and the pin is wrong by one character — which does not fail loudly, it just never matches. + // Base64 contains no spaces, so restoring them is unambiguous. + @Test + fun repairsAPinWhosePlusSignsWereNotEncoded() { + val mangled = pin.replace("+", " ") + val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?v=1&u=$url&p=$mangled&t=$token")) + assertEquals(pin, link.pin) + } + + @Test + fun toleratesSurroundingWhitespaceAndCaseFromAPaste() { + val link = assertNotNull( + EnrollmentLink.parse(" ECHOLOT://ENROLL?v=1&u=$url&p=$pin&t=$token\n") + ) + assertEquals(url, link.controlUrl) + } + + // A half-applied link is a confusing failure much later; a rejected one is an obvious failure + // now. So anything missing or unrecognised parses to null rather than to a partial config. + @Test + fun rejectsAnythingItCannotFullyUnderstand() { + val bad = listOf( + null, + "", + "not a uri", + "https://fmr-1.echo-lot.app:8443", // a plain URL is not a bootstrap link + "echolot://run?v=1&u=$url&p=$pin&t=$token", // wrong action + "echolot://enroll?v=2&u=$url&p=$pin&t=$token", // unknown link version + "echolot://enroll?v=1&p=$pin&t=$token", // no url + "echolot://enroll?v=1&u=$url&t=$token", // no pin + "echolot://enroll?v=1&u=$url&p=$pin", // no token + "echolot://enroll?v=1&u=$url&p=pin-sha256:&t=$token", // empty pin + ) + for (s in bad) assertNull(EnrollmentLink.parse(s), "should not parse: $s") + } + + // The pin is the entire basis of trust, and it only protects the connection if the connection + // is TLS. A cleartext control URL would hand the token to anyone on the path. + @Test + fun refusesACleartextControlUrl() { + assertNull(EnrollmentLink.parse("echolot://enroll?v=1&u=http://fmr-1.echo-lot.app:8443&p=$pin&t=$token")) + } + + @Test + fun aMissingVersionIsTreatedAsTheOnlyVersionThatExists() { + val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?u=$url&p=$pin&t=$token")) + assertEquals(token, link.token) + } + + @Test + fun trailingSlashesOnTheControlUrlAreNormalised() { + val link = assertNotNull(EnrollmentLink.parse("echolot://enroll?v=1&u=$url/&p=$pin&t=$token")) + assertEquals(url, link.controlUrl, "a trailing slash would double up when paths are appended") + } +} diff --git a/echolot-app/scripts/test-fmr.sh b/echolot-app/scripts/test-fmr.sh index 9740317..5a4e4e1 100644 --- a/echolot-app/scripts/test-fmr.sh +++ b/echolot-app/scripts/test-fmr.sh @@ -19,13 +19,23 @@ UDP_PORT="${ECHOLOT_UDP_PORT:-8442}" CTL_URL="https://${CTL_HOST}:${CTL_PORT}" echo "· minting enrollment token on ${SSH_HOST} ..." -TOKEN=$(ssh -o BatchMode=yes "$SSH_HOST" \ - 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \ - | python -c 'import json,sys;print(json.load(sys.stdin)["token"])') +MINTED=$(ssh -o BatchMode=yes "$SSH_HOST" \ + 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens') +TOKEN=$(printf '%s' "$MINTED" | python -c 'import json,sys;print(json.load(sys.stdin)["token"])') +# The server also returns the whole §2.1 bootstrap link. LiveEnrollmentTest redeems that link, +# which is what proves the Go side and the Kotlin side agree on its encoding — a disagreement +# there yields a pin wrong by one character, which fails much later and looks like anything but. +ENROLL_URI=$(printf '%s' "$MINTED" \ + | python -c 'import json,sys;print(json.load(sys.stdin).get("enroll_uri",""))') echo "· enrolling over ${CTL_URL} ..." -CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN}" \ - | python -c 'import json,sys;print(json.load(sys.stdin)["credential"])') +# A second token, because the one above is single-use and may be spent by LiveEnrollmentTest. +TOKEN2=$(ssh -o BatchMode=yes "$SSH_HOST" \ + 'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \ + | python -c 'import json,sys;print(json.load(sys.stdin)["token"])') +CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN2}" \ + -H "X-Echolot-App-Version: 0.2.0" \ + | python -c 'import json,sys;d=json.load(sys.stdin);print(d.get("device_credential") or d["credential"])') echo "· computing SPKI pin from served cert ..." PIN=$(echo | openssl s_client -connect "${CTL_HOST}:${CTL_PORT}" 2>/dev/null \ @@ -43,5 +53,6 @@ ECHOLOT_LIVE_PIN="$PIN" \ ECHOLOT_LIVE_CRED="$CRED" \ ECHOLOT_LIVE_UDP="${CTL_HOST}:${UDP_PORT}" \ ECHOLOT_LIVE_TARGET="${ECHOLOT_LIVE_TARGET:-fmr}" \ +ECHOLOT_ENROLL_URI="$ENROLL_URI" \ ./gradlew "$TASK" --tests "$FILTER" --info --rerun-tasks --console=plain \ - 2>&1 | grep -E "profile:|capabilities:|session:|echo |primed|mtu probe|downtrain|big_send|largest|observations bytes|Live[A-Za-z]*Test|BUILD|FAIL|PASS|^e:" || true + 2>&1 | grep -E "profile:|capabilities:|session:|echo |primed|mtu probe|downtrain|big_send|largest|observations bytes|link:|parsed:|enrolled:|refused|Live[A-Za-z]*Test|BUILD|FAIL|PASS|^e:" || true diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index edbd0c3..908f460 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -31,6 +31,7 @@ import ( "os/signal" "path/filepath" "strconv" + "strings" "sync/atomic" "syscall" "time" @@ -145,12 +146,13 @@ func serve(cfg *config.Config) error { Store: st, Sessions: sessions, Name: cfg.Name, UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), StunPort: mustPort(firstAddr(cfg.StunListen)), PinB64: pin, CertChain: cert.Certificate, - DelayedEcho: dp.SendDelayedEcho, - DownTrain: dp.DownTrain, - BigSend: dp.BigSend, - TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) }, - Runs: runStore, - AppRange: appRange, + DelayedEcho: dp.SendDelayedEcho, + DownTrain: dp.DownTrain, + BigSend: dp.BigSend, + TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) }, + Runs: runStore, + AppRange: appRange, + PublicControlURL: publicControlURL(cfg), } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -446,3 +448,24 @@ func loadOrCreateCert(cfg *config.Config) (tls.Certificate, error) { slog.Info("generated self-signed certificate", "cert", certPath) return tls.X509KeyPair(certPem, keyPem) } + +// publicControlURL is where clients should reach this server's control plane. +// +// Configured wins; otherwise the first control listen address is used, which is correct for the +// plain case (bind an address, hand out that address). A wildcard bind has no single right answer, +// so it is left to the operator rather than guessed — a link pointing at 0.0.0.0 is worse than a +// link the operator was told to configure. +func publicControlURL(cfg *config.Config) string { + if cfg.PublicControlURL != "" { + return strings.TrimRight(cfg.PublicControlURL, "/") + } + addr := firstAddr(cfg.ControlListen) + if addr == "" { + return "" + } + if strings.HasPrefix(addr, ":") || strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") { + slog.Warn("control plane is bound to a wildcard address; set ECHOLOT_PUBLIC_URL "+ + "so enrollment links point somewhere reachable", "listen", addr) + } + return "https://" + addr +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index ab3fbc4..5892754 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -63,6 +63,10 @@ type Config struct { MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive) + // Where clients reach the control plane, for enrollment links. Empty = derive from the + // first control listen address. + PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url + // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } @@ -111,6 +115,7 @@ func Load(args []string) (*Config, *Actions, error) { fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables") fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables") fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict") + fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)") diff --git a/server/internal/control/control.go b/server/internal/control/control.go index 931b3b4..6d4e7b5 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -20,6 +20,7 @@ import ( "net" "net/http" "net/netip" + "net/url" "strconv" "strings" "time" @@ -72,6 +73,10 @@ type Server struct { // server, not the client. ProvenGood func() (mtuOK, sysctlOK bool) + // PublicControlURL is where clients reach this server, for the enrollment link (§2.1). + // Empty means "derive from the address we are listening on", which is right for a plain + // deployment and wrong behind a proxy or a name — hence the override. + PublicControlURL string // AppRange is the app-version window this server will serve. Zero value means the built-in // default (see DefaultAppRange). AppRange compat.Range @@ -456,8 +461,12 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) { } slog.Info("device enrolled", "device", dev.ID, "name", dev.Name) writeJSON(w, http.StatusCreated, map[string]string{ - "device_id": dev.ID, - "credential": dev.Credential, // returned exactly once + "device_id": dev.ID, + // The spec (§2.1) names this device_credential; the first implementation shipped + // "credential". Both are sent while deployed 0.5.x clients still read the old name; + // the client prefers the spec's. Drop "credential" once nothing reads it. + "device_credential": dev.Credential, // returned exactly once + "credential": dev.Credential, // deprecated alias, see above }) } @@ -662,3 +671,16 @@ func maxOrEmpty(r compat.Range) string { } return r.Max.String() } + +// EnrollmentLink builds the §2.1 bootstrap string for a freshly minted token. +// +// The server assembles it rather than the operator, because it is the only party that knows all +// three parts at once — its own URL, its own SPKI pin, and the token. An operator copying a pin +// by hand is the step that goes wrong, and a pin wrong by one character does not fail loudly. +func (s *Server) EnrollmentLink(token string) string { + u := s.PublicControlURL + return "echolot://enroll?v=1" + + "&u=" + url.QueryEscape(strings.TrimRight(u, "/")) + + "&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) + + "&t=" + url.QueryEscape(token) +}