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
+1 -1
View File
@@ -25,6 +25,6 @@ java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaV
tasks.test {
useJUnitPlatform()
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET")
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET","ECHOLOT_ENROLL_URI")
.forEach { k -> System.getenv(k)?.let { environment(k, it) } }
}
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.EnrollmentLink
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Enrolls against a LIVE server using the link the server itself minted (probe-protocol.md §2.1).
*
* This is the test that matters for enrollment, because the failure mode it guards against is a
* *disagreement* between two programs: the Go side assembles the link, the Kotlin side takes it
* apart, and if they differ by one percent-encoding the pin is wrong by one character — which
* does not fail loudly, it fails as an inscrutable TLS error days later. A unit test on either
* side alone cannot see that.
*
* Needs ECHOLOT_ENROLL_URI (minted over SSH by scripts/test-fmr.sh); self-skips without it.
*/
class LiveEnrollmentTest {
private val enrollUri = System.getenv("ECHOLOT_ENROLL_URI")
@Test
fun enrollsFromTheServersOwnLink() {
if (enrollUri.isNullOrBlank()) {
println("LiveEnrollmentTest skipped (no ECHOLOT_ENROLL_URI)"); return
}
println("link: ${enrollUri.take(60)}")
val link = assertNotNull(
EnrollmentLink.parse(enrollUri),
"the client could not parse a link the server produced — the two sides disagree",
)
println("parsed: url=${link.controlUrl} pin=${link.pin.take(12)}… token=${link.token.take(8)}")
// Redeeming applies the pin to the very request that spends the token, so a wrong pin
// fails here at the handshake rather than after the token is gone.
val enrolled = link.redeem(deviceName = "live-test", appVersion = "0.2.0")
assertTrue(enrolled.credential.isNotBlank(), "no credential came back")
assertTrue(enrolled.deviceId.isNotBlank(), "no device id came back")
println("enrolled: device=${enrolled.deviceId} server=${enrolled.profile.name} " +
"${enrolled.profile.serverVersion}")
// The credential must actually work, and the pin from the link must be the one that
// verifies the server — that is the whole claim the link is making.
assertEquals(link.controlUrl, enrolled.controlUrl)
assertTrue(enrolled.profile.capabilities.contains("udp-probe"),
"profile fetched with the new credential looks wrong: ${enrolled.profile.capabilities}")
// Single-use: a token that still works after redemption is a token an attacker can reuse.
try {
link.redeem(deviceName = "should-not-happen", appVersion = "0.2.0")
fail("the enrollment token was accepted twice — it must be single-use")
} catch (t: Throwable) {
println("second redemption correctly refused: ${t.message?.take(120)}")
}
}
}