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
@@ -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")
}
}