diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt index 80ae18b..36a67a7 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt @@ -184,6 +184,33 @@ class ControlClient( open("/v1/runs/$runId", "DELETE", credential).responseCode } + /** + * Ties this device to the person the ID token identifies. + * + * The device credential proves *which device*, the token proves *which person*; the server + * requires both. Returns the raw JSON reply (account id and display name). + */ + fun linkAccount(credential: String, idToken: String): String { + val conn = open("/v1/account/link", "POST", credential) + writeJson(conn, """{"id_token":${jstr(idToken)}}""") + val text = body(conn) + check(conn.responseCode in 200..299) { "sign-in failed: ${conn.responseCode} $text" } + return text + } + + /** Signs out on this device. The device stays enrolled. */ + fun unlinkAccount(credential: String) { + open("/v1/account/link", "DELETE", credential).responseCode + } + + /** Whether anyone is signed in on this device, and who. */ + fun accountStatus(credential: String): String { + val conn = open("/v1/account", "GET", credential) + val text = body(conn) + check(conn.responseCode == 200) { "account status failed: ${conn.responseCode} $text" } + return text + } + fun observations(credential: String, sessionId: String): String { val conn = open("/v1/sessions/$sessionId/observations", "GET", credential) val text = body(conn) diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt index 2fb5ff0..515476a 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt @@ -74,6 +74,25 @@ data class CompatInfo( @SerialName("app_max") val appMax: String = "", ) +/** + * How to sign in to this server's identity provider, advertised so the app can offer the button + * only when there is something behind it — and drive the flow without anyone typing an issuer URL. + */ +@Serializable +data class AuthInfo( + val enabled: Boolean = false, + val issuer: String = "", + @SerialName("client_id") val clientId: String = "", + val flow: String = "", + @SerialName("redirect_uri") val redirectUri: String = "", + val scopes: String = "openid profile email", + @SerialName("authorization_endpoint") val authorizationEndpoint: String = "", + @SerialName("token_endpoint") val tokenEndpoint: String = "", + @SerialName("end_session_endpoint") val endSessionEndpoint: String = "", + /** Present when the server has an issuer configured but could not reach it. */ + @SerialName("discovery_error") val discoveryError: String? = null, +) + @Serializable data class Profile( @SerialName("profile_version") val profileVersion: Int = 0, @@ -86,6 +105,7 @@ data class Profile( val pins: List = emptyList(), val uploads: UploadPolicy = UploadPolicy(), val compat: CompatInfo = CompatInfo(), + val auth: AuthInfo = AuthInfo(), ) { fun supports(capability: String) = capability in capabilities } diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/OidcLogin.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/OidcLogin.kt new file mode 100644 index 0000000..cc7b3fa --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/OidcLogin.kt @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** + * Sign-in for the app: authorization code with PKCE (RFC 7636). + * + * The app is a *public* client — it ships to devices, so any secret compiled into it can be read + * out of the APK with `unzip` and `strings`. PKCE is what replaces the client secret, and it + * defends a specific attack that matters here more than most places: the redirect comes back + * through a custom URI scheme, and on Android *any* app may register `echolot://`. A malicious one + * could intercept the callback and take the authorization code. Because the code can only be + * exchanged by presenting the verifier — which never left this process and cannot be derived from + * the challenge that did — a stolen code is worth nothing. + * + * Nothing from the IdP is kept afterwards. The ID token is used once, to prove to the server who + * is signing in, and then discarded: the device credential is what authenticates every later + * request. So there are no access tokens to store, no refresh tokens to rotate, and no token + * lifetime for the app to manage. + */ +object OidcLogin { + + /** A started sign-in. [verifier] and [state] must survive until the callback returns. */ + data class Pending(val authorizationUrl: String, val verifier: String, val state: String) + + /** + * Builds the authorization URL and the secrets that must be held until the callback. + * + * Everything comes from the server's profile rather than being compiled in, so pointing the + * app at a different server with a different IdP is configuration, not a rebuild. + */ + fun begin(auth: AuthInfo, random: SecureRandom = SecureRandom()): Pending { + require(auth.enabled && auth.authorizationEndpoint.isNotBlank()) { + "this server has no identity provider configured" + } + val verifier = randomUrlSafe(random) + val state = randomUrlSafe(random) + val challenge = b64(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())) + + val q = buildString { + append("response_type=code") + append("&client_id=").append(enc(auth.clientId)) + append("&redirect_uri=").append(enc(auth.redirectUri)) + append("&scope=").append(enc(auth.scopes)) + append("&state=").append(enc(state)) + append("&code_challenge=").append(enc(challenge)) + append("&code_challenge_method=S256") + } + val sep = if (auth.authorizationEndpoint.contains('?')) "&" else "?" + return Pending(auth.authorizationEndpoint + sep + q, verifier, state) + } + + /** What came back on the `echolot://auth` redirect. */ + data class Callback(val code: String?, val state: String?, val error: String?) + + /** Parses the redirect URI the browser handed back to the app. */ + fun parseCallback(uri: String): Callback { + val q = uri.substringAfter('?', "") + var code: String? = null + var state: String? = null + var error: String? = null + for (pair in q.split('&')) { + val k = pair.substringBefore('=') + val v = dec(pair.substringAfter('=', "")) + when (k) { + "code" -> code = v + "state" -> state = v + "error" -> error = v + "error_description" -> if (error != null) error = "$error: $v" + } + } + return Callback(code, state, error) + } + + /** The sign-in failed in a way worth showing someone, rather than a transport error. */ + class LoginFailed(message: String) : Exception(message) + + /** + * Exchanges the code for an ID token. + * + * The state is compared before anything else happens. A callback whose state does not match + * the one this process generated did not come from a flow this process started — which is + * precisely how an attacker gets a victim to complete *their* login — so it is refused before + * the code is spent. + */ + fun complete(auth: AuthInfo, pending: Pending, callbackUri: String): String { + val cb = parseCallback(callbackUri) + if (cb.error != null) throw LoginFailed(cb.error) + if (cb.state.isNullOrEmpty() || cb.state != pending.state) { + throw LoginFailed("this sign-in did not start on this device — start again") + } + val code = cb.code ?: throw LoginFailed("the identity provider returned no authorization code") + + val body = buildString { + append("grant_type=authorization_code") + append("&code=").append(enc(code)) + append("&redirect_uri=").append(enc(auth.redirectUri)) + append("&client_id=").append(enc(auth.clientId)) + append("&code_verifier=").append(enc(pending.verifier)) + } + val conn = (URL(auth.tokenEndpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 15_000 + readTimeout = 15_000 + setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + setRequestProperty("Accept", "application/json") + } + conn.outputStream.use { it.write(body.toByteArray()) } + val text = try { + val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream + stream?.bufferedReader()?.use { it.readText() } ?: "" + } catch (e: IOException) { + throw LoginFailed("could not reach the identity provider: ${e.message}") + } + if (conn.responseCode !in 200..299) { + throw LoginFailed("the identity provider refused the sign-in (${conn.responseCode})") + } + val idToken = runCatching { + Json.parseToJsonElement(text).jsonObject["id_token"]?.jsonPrimitive?.content + }.getOrNull() + return idToken?.takeIf { it.isNotBlank() } + ?: throw LoginFailed("the identity provider returned no id_token") + } + + private fun randomUrlSafe(random: SecureRandom): String = + ByteArray(32).also(random::nextBytes).let(::b64) + + private fun b64(b: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(b) + private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8") + private fun dec(s: String): String = + runCatching { java.net.URLDecoder.decode(s, "UTF-8") }.getOrDefault(s) +} diff --git a/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/OidcLoginTest.kt b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/OidcLoginTest.kt new file mode 100644 index 0000000..59ceb00 --- /dev/null +++ b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/OidcLoginTest.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.protocol + +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class OidcLoginTest { + + private val auth = AuthInfo( + enabled = true, + issuer = "https://id.example.net/application/o/echolot-app/", + clientId = "the-client", + redirectUri = "echolot://auth", + scopes = "openid profile email", + authorizationEndpoint = "https://id.example.net/application/o/authorize/", + tokenEndpoint = "https://id.example.net/application/o/token/", + ) + + @Test + fun theAuthorizationUrlCarriesEverythingTheIdPNeeds() { + val p = OidcLogin.begin(auth) + val url = p.authorizationUrl + assertTrue(url.startsWith(auth.authorizationEndpoint + "?"), url) + for (part in listOf( + "response_type=code", + "client_id=the-client", + "redirect_uri=echolot%3A%2F%2Fauth", + "code_challenge_method=S256", + "scope=openid+profile+email", + )) { + assertTrue(url.contains(part), "missing $part in $url") + } + assertTrue(url.contains("code_challenge="), url) + // The verifier itself must never appear in the URL — that is the entire point of PKCE. + assertTrue(!url.contains(p.verifier), "the code verifier leaked into the authorize URL") + } + + // Two sign-ins must not share a verifier or state, or one intercepted flow compromises the next. + @Test + fun everySignInGetsFreshSecrets() { + val a = OidcLogin.begin(auth, SecureRandom()) + val b = OidcLogin.begin(auth, SecureRandom()) + assertNotEquals(a.verifier, b.verifier) + assertNotEquals(a.state, b.state) + assertTrue(a.verifier.length >= 43, "verifier is shorter than RFC 7636 allows") + } + + @Test + fun parsesTheRedirectTheBrowserHandsBack() { + val cb = OidcLogin.parseCallback("echolot://auth?code=abc123&state=xyz") + assertEquals("abc123", cb.code) + assertEquals("xyz", cb.state) + } + + @Test + fun parsesAnErrorRedirect() { + val cb = OidcLogin.parseCallback("echolot://auth?error=access_denied&error_description=User%20said%20no") + assertEquals("access_denied", cb.error?.substringBefore(":")) + assertTrue(cb.code == null) + } + + // A callback whose state does not match is how an attacker gets someone to complete *their* + // sign-in. It must be refused before the code is spent, without any network call. + @Test + fun aMismatchedStateIsRefusedBeforeTheCodeIsSpent() { + val p = OidcLogin.begin(auth) + val e = assertFailsWith { + OidcLogin.complete(auth, p, "echolot://auth?code=stolen&state=not-ours") + } + assertTrue(e.message!!.contains("did not start on this device"), e.message!!) + } + + @Test + fun aMissingStateIsRefused() { + val p = OidcLogin.begin(auth) + assertFailsWith { + OidcLogin.complete(auth, p, "echolot://auth?code=abc") + } + } + + @Test + fun anErrorRedirectSurfacesTheReason() { + val p = OidcLogin.begin(auth) + val e = assertFailsWith { + OidcLogin.complete(auth, p, "echolot://auth?error=access_denied&state=${p.state}") + } + assertTrue(e.message!!.contains("access_denied")) + } + + @Test + fun refusesToStartWhenTheServerHasNoIdentityProvider() { + assertFailsWith { OidcLogin.begin(AuthInfo(enabled = false)) } + } +} diff --git a/server/internal/control/control.go b/server/internal/control/control.go index 506ef8f..1263f39 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -755,11 +755,19 @@ func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } - list := s.Runs.List(dev.ID) + list := s.Runs.ListFor(s.visibleDevices(dev)) if list == nil { list = []runs.Meta{} } - writeJSON(w, http.StatusOK, map[string]any{"runs": list}) + writeJSON(w, http.StatusOK, map[string]any{ + "runs": list, + // Says whose history this is, so a client can show "3 devices" rather than leaving the + // user to wonder why runs from another phone appeared. + "scope": map[string]any{ + "account_id": dev.AccountID, + "devices": len(s.visibleDevices(dev)), + }, + }) } func (s *Server) getRun(w http.ResponseWriter, r *http.Request) { @@ -768,9 +776,14 @@ func (s *Server) getRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } - // Scoped to the calling device's own directory: one device cannot read another's runs by - // guessing a run id. - b, err := s.Runs.Get(dev.ID, r.PathValue("id")) + // Resolved against the caller's own devices only, so a run id from another account is not + // found rather than being fetched from wherever it happens to live. + owner, ok := s.Runs.OwnerOf(s.visibleDevices(dev), r.PathValue("id")) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"}) + return + } + b, err := s.Runs.Get(owner, r.PathValue("id")) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"}) return @@ -785,7 +798,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } - if err := s.Runs.Delete(dev.ID, r.PathValue("id")); err != nil { + owner, ok := s.Runs.OwnerOf(s.visibleDevices(dev), r.PathValue("id")) + if !ok { + w.WriteHeader(http.StatusNoContent) // delete is idempotent; absent is the desired state + return + } + if err := s.Runs.Delete(owner, r.PathValue("id")); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -928,3 +946,17 @@ func (s *Server) accountStatus(w http.ResponseWriter, r *http.Request) { "device_id": dev.ID, }) } + +// visibleDevices is the set of devices whose runs the caller may read. +// +// Signed in: every device on the same account, which is what an account is for. Not signed in: +// only itself — anonymous devices are not a group, and treating the absent account as a shared +// one would let any of them read all the others. +func (s *Server) visibleDevices(dev *store.Device) []string { + if dev.LinkedToAccount() { + if ids := s.Store.DeviceIDsForAccount(dev.AccountID); len(ids) > 0 { + return ids + } + } + return []string{dev.ID} +} diff --git a/server/internal/runs/account_test.go b/server/internal/runs/account_test.go new file mode 100644 index 0000000..7ff71ce --- /dev/null +++ b/server/internal/runs/account_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package runs + +import ( + "testing" + "time" +) + +// Account scoping widens what a caller can read, so the test that matters is the one about what +// it must NOT widen: a run id from another account has to be invisible, not merely unlisted. +func TestAccountScopingDoesNotReachOtherAccounts(t *testing.T) { + s, _ := open(t, DefaultPolicy()) + + // Two devices on one account, one device belonging to somebody else. + mine := []string{"phone-a", "tablet-a"} + for i, d := range mine { + if _, err := s.Put(d, doc("run-"+d, AnonFull), true); err != nil { + t.Fatal(err) + } + _ = i + time.Sleep(2 * time.Millisecond) + } + if _, err := s.Put("phone-b", doc("run-secret", AnonFull), true); err != nil { + t.Fatal(err) + } + + got := s.ListFor(mine) + if len(got) != 2 { + t.Fatalf("account history has %d runs, want 2", len(got)) + } + for _, m := range got { + if m.ID == "run-secret" { + t.Fatal("another account's run appeared in the history") + } + } + + // The decisive one: knowing the id is not enough. + if _, ok := s.OwnerOf(mine, "run-secret"); ok { + t.Fatal("a run id from another account resolved against this account's devices") + } + if owner, ok := s.OwnerOf(mine, "run-phone-a"); !ok || owner != "phone-a" { + t.Fatalf("own run did not resolve: owner=%q ok=%v", owner, ok) + } + // A sibling device's run must resolve — that is the point of the feature. + if owner, ok := s.OwnerOf(mine, "run-tablet-a"); !ok || owner != "tablet-a" { + t.Fatalf("sibling device's run did not resolve: owner=%q ok=%v", owner, ok) + } +} + +func TestAccountHistoryIsNewestFirstAcrossDevices(t *testing.T) { + s, _ := open(t, DefaultPolicy()) + if _, err := s.Put("phone", doc("older", AnonFull), true); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + if _, err := s.Put("tablet", doc("newer", AnonFull), true); err != nil { + t.Fatal(err) + } + got := s.ListFor([]string{"phone", "tablet"}) + if len(got) != 2 || got[0].ID != "newer" { + t.Fatalf("not merged newest-first: %+v", got) + } +} + +func TestEmptyDeviceSetSeesNothing(t *testing.T) { + s, _ := open(t, DefaultPolicy()) + if _, err := s.Put("someone", doc("run-1", AnonFull), true); err != nil { + t.Fatal(err) + } + if got := s.ListFor(nil); len(got) != 0 { + t.Fatalf("an empty device set returned %d runs", len(got)) + } + if _, ok := s.OwnerOf(nil, "run-1"); ok { + t.Fatal("a run resolved against an empty device set") + } +} diff --git a/server/internal/runs/runs.go b/server/internal/runs/runs.go index f7e55bb..e2d2c05 100644 --- a/server/internal/runs/runs.go +++ b/server/internal/runs/runs.go @@ -209,6 +209,38 @@ func (s *Store) List(deviceID string) []Meta { return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID))) } +// ListFor returns the runs of several devices at once, newest first. +// +// This is what makes an account mean something: three phones signed in to one account produce one +// history, which is the main reason to have accounts beyond upload permission. +func (s *Store) ListFor(deviceIDs []string) []Meta { + s.mu.Lock() + defer s.mu.Unlock() + var out []Meta + for _, id := range deviceIDs { + out = append(out, s.listLocked(filepath.Join(s.dir, sanitizeID(id)))...) + } + sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) }) + return out +} + +// OwnerOf reports which of these devices holds runID, so a caller can be granted access to a run +// belonging to a sibling device without being able to name an arbitrary device. +// +// The search is over an allow-list the caller never supplies directly — it comes from the account +// — so a run id from another account simply is not found. +func (s *Store) OwnerOf(deviceIDs []string, runID string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range deviceIDs { + p := filepath.Join(s.dir, sanitizeID(id), sanitizeID(runID)+".json") + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + return id, true + } + } + return "", false +} + // Get returns the stored document bytes for one run. func (s *Store) Get(deviceID, runID string) ([]byte, error) { s.mu.Lock() diff --git a/server/internal/store/account_test.go b/server/internal/store/account_test.go new file mode 100644 index 0000000..c88c6ff --- /dev/null +++ b/server/internal/store/account_test.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package store + +import "testing" + +// The empty account must never match. Devices nobody has signed in on are not a group — they are +// unrelated devices that share the absence of an owner — and treating that as an account would +// let any anonymous device read every other anonymous device's runs. +func TestTheEmptyAccountIsNotAGroup(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"anon-1", "anon-2"} { + s.data.Devices = append(s.data.Devices, Device{ID: id}) + } + s.data.Devices = append(s.data.Devices, + Device{ID: "mine-1", AccountID: "iss#me"}, + Device{ID: "mine-2", AccountID: "iss#me"}, + Device{ID: "theirs", AccountID: "iss#them"}) + + if got := s.DeviceIDsForAccount(""); len(got) != 0 { + t.Fatalf("the empty account matched %v", got) + } + if got := s.DeviceIDsForAccount("iss#me"); len(got) != 2 { + t.Fatalf("account has %v, want both of its devices", got) + } + if got := s.DeviceIDsForAccount("iss#them"); len(got) != 1 || got[0] != "theirs" { + t.Fatalf("wrong devices for the other account: %v", got) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 58dc9e3..195bee4 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -165,6 +165,26 @@ func (s *Store) LinkAccount(deviceID, accountID, displayName string) error { return errors.New("no such device") } +// DeviceIDsForAccount returns every device signed in to the same account. +// +// The empty account is never matched: devices that nobody has signed in on are not a group, they +// are unrelated devices that happen to share the absence of an owner. Treating them as an account +// would let any anonymous device read every other anonymous device's runs. +func (s *Store) DeviceIDsForAccount(accountID string) []string { + if accountID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for _, d := range s.data.Devices { + if d.AccountID == accountID { + out = append(out, d.ID) + } + } + return out +} + // Devices returns a copy of the device list, for the admin UI. func (s *Store) Devices() []Device { s.mu.Lock()