enrollment: the server mints the §2.1 bootstrap link, the app consumes it
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s

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:
mrambossek
2026-08-01 12:06:22 +02:00
co-authored by Claude Fable 5
parent 8166611af1
commit ad85f3bfcd
15 changed files with 533 additions and 17 deletions
@@ -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(),