protocol: enrollment links carry the public name, not the endpoint

Sharing port 443 between the admin UI and the control plane forces two
hostnames — one port and one name is one certificate, and the two need
different ones. That difference had been leaking into every enrollment
link, so an operator handed out fmr-1.echo-lot.app when the thing they
and their users know is fmr.echo-lot.app.

The link now carries the public name and the app asks GET /v1/discover
where to actually connect. The endpoint is plumbing: it exists to select
a certificate, and nobody needs to see it.

Discovery hands out an address and never a pin. The pin stays in the
link. Fetching it over an ordinary TLS connection would make pinning
worth exactly what the certificate authorities are worth, and pinning is
there to survive one the operator does not control — a root injected by
corporate device management, say, which is unremarkable on the networks
this tool gets pointed at. With the pin pre-shared, an intercepted
discovery can only send a device somewhere the pin will not match: an
outage, not a compromise.

Optional on both sides. A server that does not answer, or a link that
already names the control endpoint, works unchanged — enrollment must not
start failing because a lookup did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 00:13:27 +02:00
co-authored by Claude Opus 5
parent a720e84411
commit 082a2314ef
6 changed files with 120 additions and 2 deletions
@@ -45,11 +45,22 @@ data class EnrollmentLink(
* 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)
// The link may name the server's public address rather than its control endpoint, so that
// a person is handed a name they recognise. Ask where to actually connect.
//
// Only the address comes from here. The pin still comes from the link, because a pin
// fetched over an ordinary TLS connection would be worth exactly what the certificate
// authorities are worth — and pinning exists to survive one the operator does not
// control, such as a root injected by corporate device management. An intercepted
// discovery can therefore send this device to the wrong host, where the pin will not
// match: an outage, not a compromise.
val endpoint = discover(controlUrl) ?: controlUrl
val client = ControlClient(endpoint, setOf(pin), appVersion)
val response = client.enroll(token, deviceName)
val profile = client.profile(response.credential)
return Enrolled(
controlUrl = controlUrl,
controlUrl = endpoint,
publicUrl = controlUrl,
pin = pin,
credential = response.credential,
deviceId = response.deviceId,
@@ -57,6 +68,29 @@ data class EnrollmentLink(
)
}
/**
* Asks a server where its control plane lives. Null when it does not say, or cannot be asked.
*
* Deliberately forgiving: a server that predates this, or one whose link already names the
* control endpoint directly, simply answers nothing and the link's own URL is used. Enrollment
* must not start failing because an optional lookup did.
*/
private fun discover(publicUrl: String): String? = runCatching {
val conn = (java.net.URL(publicUrl.trimEnd('/') + "/v1/discover").openConnection()
as java.net.HttpURLConnection).apply {
connectTimeout = 8_000
readTimeout = 8_000
setRequestProperty("Accept", "application/json")
}
if (conn.responseCode !in 200..299) return null
val body = conn.inputStream.bufferedReader().use { it.readText() }
kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
.parseToJsonElement(body)
.let { (it as kotlinx.serialization.json.JsonObject)["control_url"] }
?.let { (it as kotlinx.serialization.json.JsonPrimitive).content }
?.takeIf { it.isNotBlank() }
}.getOrNull()
companion object {
const val SCHEME = "echolot"
const val HOST = "enroll"
@@ -111,7 +145,15 @@ data class EnrollmentLink(
/** A server this device is now enrolled with, ready to be stored in settings. */
data class Enrolled(
/** Where this device connects: the endpoint whose certificate the pin matches. */
val controlUrl: String,
/**
* The address a person was given, kept for display.
*
* Shown instead of [controlUrl] because the endpoint is plumbing — it exists to select a
* certificate — while this is the name the operator handed out and would recognise.
*/
val publicUrl: String,
val pin: String,
val credential: String,
val deviceId: String,