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:
co-authored by
Claude Opus 5
parent
a720e84411
commit
082a2314ef
@@ -111,6 +111,31 @@ DELETE /v1/sessions/{id}
|
||||
|
||||
Per-credential and per-source-IP token buckets on: session creation, actions, UDP packets, bytes. `429` on control plane; silent drop on data plane (probes must tolerate loss anyway). All reflected/generated traffic goes **only** to the session's observed source address (or, for connect-back, the source address of the session-creating request). Data-plane responses to unauthenticated packets are never larger than the request (§3.4).
|
||||
|
||||
### 2.2 `GET /v1/discover` — where the control plane lives
|
||||
|
||||
Unauthenticated, and says almost nothing: the control-plane URL and the server's display name.
|
||||
|
||||
```json
|
||||
{ "control_url": "https://probe.example.net", "name": "example" }
|
||||
```
|
||||
|
||||
It exists so an enrollment link can carry the name a person recognises while the app still connects
|
||||
to the name that selects the pinned certificate. When a server shares port 443 between its admin UI
|
||||
and its control plane, those must be different hostnames — one port and one name is one certificate,
|
||||
and the two need different ones (a browser-trusted certificate, and a long-lived self-signed one the
|
||||
client pins). Without discovery, the difference leaks into every enrollment link an operator hands
|
||||
out.
|
||||
|
||||
**It hands out an address, never a pin.** The pin travels in the link itself. Serving it here would
|
||||
reduce pinning to whatever the certificate authorities are worth, and pinning exists precisely to
|
||||
survive one the operator does not control — a root injected by corporate device management, for
|
||||
instance, which is unremarkable on the networks this tool is pointed at. Because the pin is
|
||||
pre-shared, an intercepted discovery response can only send a device to the wrong host, where the
|
||||
pin will not match: an outage, not a compromise.
|
||||
|
||||
Clients treat it as optional. A server that does not answer, or a link that already names the
|
||||
control endpoint, works unchanged — enrollment must not begin failing because a lookup did.
|
||||
|
||||
## 3. UDP probe protocol
|
||||
|
||||
### 3.1 Packet header (fixed 32 bytes, network byte order)
|
||||
|
||||
@@ -125,6 +125,7 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
|
||||
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
|
||||
settings.serverUrl = enrolled.controlUrl
|
||||
settings.serverPublicUrl = enrolled.publicUrl
|
||||
settings.serverPin = enrolled.pin
|
||||
settings.serverCredential = enrolled.credential
|
||||
val head = "Enrolled with ${enrolled.profile.name} " +
|
||||
|
||||
@@ -97,6 +97,17 @@ class Settings(context: Context) {
|
||||
get() = prefs.getString(SERVER_PIN, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
|
||||
|
||||
/**
|
||||
* The address the operator handed out, for showing to a person.
|
||||
*
|
||||
* Separate from [serverUrl], which is the endpoint actually dialled. They differ when the
|
||||
* server publishes one public name and points devices at another to select its pinned
|
||||
* certificate — a detail worth keeping out of the user's face but not out of the settings.
|
||||
*/
|
||||
var serverPublicUrl: String
|
||||
get() = (prefs.getString(SERVER_PUBLIC_URL, "") ?: "").ifBlank { serverUrl }
|
||||
set(v) = prefs.edit().putString(SERVER_PUBLIC_URL, v.trim()).apply()
|
||||
|
||||
var serverCredential: String
|
||||
get() = prefs.getString(SERVER_CRED, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
|
||||
@@ -172,6 +183,7 @@ class Settings(context: Context) {
|
||||
const val SERVER_URL = "server_url"
|
||||
const val SERVER_PIN = "server_pin"
|
||||
const val SERVER_CRED = "server_credential"
|
||||
const val SERVER_PUBLIC_URL = "server_public_url"
|
||||
const val CANARY_ZONE = "server_canary_zone"
|
||||
const val PENDING_VERIFIER = "pending_auth_verifier"
|
||||
const val PENDING_STATE = "pending_auth_state"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -150,6 +150,17 @@ func mintEnrollToken(cfg *config.Config, note string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// controlURL is the address devices connect to: the hostname that selects the pinned certificate.
|
||||
//
|
||||
// Falls back to the public URL when no separate control hostname is configured, so a server that
|
||||
// does not share the admin port keeps answering discovery with something usable.
|
||||
func controlURL(cfg *config.Config) string {
|
||||
if cfg.ControlHostname == "" {
|
||||
return cfg.PublicControlURL
|
||||
}
|
||||
return "https://" + cfg.ControlHostname
|
||||
}
|
||||
|
||||
func serve(cfg *config.Config) error {
|
||||
slog.Info("echolot-server starting", "version", Version, "mode",
|
||||
map[bool]string{true: "container", false: "native"}[cfg.Docker],
|
||||
@@ -350,6 +361,10 @@ func serve(cfg *config.Config) error {
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
Secure: adminSecure,
|
||||
EnrollLink: ctl.EnrollmentLink,
|
||||
// Where devices should connect, for /v1/discover. Derived from the control hostname so it
|
||||
// cannot drift from the name that actually selects the pinned certificate.
|
||||
ControlURL: controlURL(cfg),
|
||||
ServerName: cfg.Name,
|
||||
SelfTest: func() any { return selftestPtr.Load() },
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ type Server struct {
|
||||
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
|
||||
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
|
||||
EnrollLink func(token string) string
|
||||
// ControlURL is where devices should actually connect, handed out by /v1/discover so the
|
||||
// enrollment link can show the public name instead. ServerName is for display.
|
||||
ControlURL string
|
||||
ServerName string
|
||||
// SelfTest and Version render on the dashboard.
|
||||
SelfTest func() any
|
||||
Version string
|
||||
@@ -77,6 +81,25 @@ func (s *Server) Handler() http.Handler {
|
||||
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
|
||||
})
|
||||
|
||||
// Unauthenticated on purpose, and deliberately says almost nothing: where the control plane
|
||||
// is, and nothing about who may talk to it.
|
||||
//
|
||||
// This exists so an enrollment link can carry the name a person recognises while the app
|
||||
// still connects to the name that selects the pinned certificate. It hands out an address,
|
||||
// never a pin — the pin travels in the link itself. Serving the pin here would collapse
|
||||
// pinning to whatever the CA system says, and pinning exists precisely to survive a
|
||||
// certificate authority the operator does not control.
|
||||
//
|
||||
// So the worst an intercepted discovery can do is send a device to the wrong host, where the
|
||||
// pin check fails. That is a denial of service, not a compromise.
|
||||
mux.HandleFunc("GET /v1/discover", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"control_url": s.ControlURL,
|
||||
"name": s.ServerName,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /login", s.loginForm)
|
||||
mux.HandleFunc("POST /login", s.loginSubmit)
|
||||
mux.HandleFunc("GET /auth/start", s.oidcStart)
|
||||
|
||||
Reference in New Issue
Block a user