diff --git a/docs/probe-protocol.md b/docs/probe-protocol.md index 4e4bf38..148e091 100644 --- a/docs/probe-protocol.md +++ b/docs/probe-protocol.md @@ -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) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt index 4a67204..75d16a4 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -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} " + diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt index 0b7e658..4d5ec8e 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt @@ -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" diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt index c2a3ddb..d8a6cb3 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Enrollment.kt @@ -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, diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index ac9269f..4e13307 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -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, } diff --git a/server/internal/adminui/auth.go b/server/internal/adminui/auth.go index 752ef76..e1b0a54 100644 --- a/server/internal/adminui/auth.go +++ b/server/internal/adminui/auth.go @@ -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)