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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user