enrollment: the server mints the §2.1 bootstrap link, the app consumes it
POST /admin/enroll-tokens now returns the whole link, not just the token: echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8166611af1
commit
ad85f3bfcd
@@ -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
|
||||
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Enrollment bootstrap (probe-protocol.md §2.1): echolot://enroll?v=1&u=…&p=…&t=…
|
||||
Scanning a QR or tapping a link the operator sent configures the server in one
|
||||
action, instead of transcribing a URL, a base64 pin and a token by hand — the pin
|
||||
in particular fails silently when it is wrong by one character.
|
||||
-->
|
||||
<intent-filter android:autoVerify="false">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="echolot" android:host="enroll" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -59,6 +59,17 @@ class MainActivity : ComponentActivity() {
|
||||
// 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
|
||||
|
||||
// An echolot://enroll link (QR scan, or a link the operator sent) opens the
|
||||
// app straight into settings with the enrollment already done, so the user
|
||||
// sees the result rather than a form they still have to fill in.
|
||||
val enrollUri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
|
||||
androidx.compose.runtime.LaunchedEffect(enrollUri) {
|
||||
if (enrollUri != null) {
|
||||
vm.enroll(enrollUri)
|
||||
screen = Screen.SETTINGS
|
||||
}
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||
if (autorun) vm.run(devUpload = true)
|
||||
}
|
||||
@@ -89,6 +100,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
},
|
||||
onCheckServer = vm::checkServer,
|
||||
onEnroll = vm::enroll,
|
||||
serverStatus = vm.state.archiveStatus,
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
|
||||
@@ -108,6 +108,35 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems an enrollment link and stores the resulting server configuration (§2.1).
|
||||
*
|
||||
* Everything is written at once or not at all: a half-applied server — say a URL and pin with
|
||||
* no credential — fails later, somewhere else, with an error that points at the wrong thing.
|
||||
* Blocking; callers run it off the main thread.
|
||||
*/
|
||||
fun enroll(link: String, deviceName: String?): String {
|
||||
val parsed = app.echo_lot.protocol.EnrollmentLink.parse(link)
|
||||
?: return "That does not look like an Echolot enrollment link. It should start with " +
|
||||
"echolot://enroll and carry a URL, a pin and a token."
|
||||
return try {
|
||||
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
|
||||
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
|
||||
settings.serverUrl = enrolled.controlUrl
|
||||
settings.serverPin = enrolled.pin
|
||||
settings.serverCredential = enrolled.credential
|
||||
val head = "Enrolled with ${enrolled.profile.name} " +
|
||||
"(server ${enrolled.profile.serverVersion})."
|
||||
if (compat.message != null) head + " " + compat.message else head
|
||||
} catch (e: VersionRefused) {
|
||||
"That server will not serve this app: ${e.message}"
|
||||
} catch (t: Throwable) {
|
||||
// The commonest causes are a spent token and a wrong pin, and they look nothing alike
|
||||
// in the message — so pass it through rather than flattening it to "enrollment failed".
|
||||
"Enrollment failed: ${t.message ?: t.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one archived run to the configured server, redacting first.
|
||||
*
|
||||
|
||||
@@ -196,6 +196,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
fun archivedBytes(): Long = store.totalBytes()
|
||||
|
||||
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
|
||||
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(archiveStatus = "enrolling …")
|
||||
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.enroll(link, deviceName) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings-screen action: report what the configured server is and whether we can use it. */
|
||||
fun checkServer() {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -47,6 +47,7 @@ fun SettingsScreen(
|
||||
onDeleteAll: () -> 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(),
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
|
||||
@@ -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)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=<token>
|
||||
* ```
|
||||
*
|
||||
* 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<String, String>()
|
||||
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,
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user