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