runs: scope by account; app: the PKCE half of signing in
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s

Three phones on one account now produce one history, which is the main reason to
have accounts beyond upload permission. GET /v1/runs returns the account's runs
and says how many devices contributed; fetching and deleting resolve a run id
against the caller's own devices, so an id from another account is not found
rather than fetched from wherever it happens to live.

The rule that needed stating: the empty account is never a group. Devices nobody
has signed in on are unrelated devices that share the absence of an owner, and
matching on "" would let any anonymous device read every other one's runs.
Tested, along with sibling-device access working and cross-account access not.

App side: authorization code with PKCE. The app is a public client - anything
compiled into an APK can be read out with unzip and strings - and the redirect
returns through a custom URI scheme that any app on the device may register, so
an intercepted code is a real risk. PKCE makes a stolen code worthless: it can
only be exchanged by presenting a verifier that never left the process.

A callback whose state does not match is refused before the code is spent and
before any network call, since that is exactly how someone gets a victim to
complete the attacker's sign-in.

Nothing from the IdP is retained. The ID token is used once to prove who is
signing in and then discarded; the device credential authenticates everything
afterwards. No access tokens to store, no refresh tokens to rotate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 19:46:32 +02:00
co-authored by Claude Fable 5
parent 0eaba6150b
commit 4e6f2da3fb
9 changed files with 493 additions and 6 deletions
@@ -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)
@@ -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<String> = emptyList(),
val uploads: UploadPolicy = UploadPolicy(),
val compat: CompatInfo = CompatInfo(),
val auth: AuthInfo = AuthInfo(),
) {
fun supports(capability: String) = capability in capabilities
}
@@ -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)
}
@@ -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.LoginFailed> {
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.LoginFailed> {
OidcLogin.complete(auth, p, "echolot://auth?code=abc")
}
}
@Test
fun anErrorRedirectSurfacesTheReason() {
val p = OidcLogin.begin(auth)
val e = assertFailsWith<OidcLogin.LoginFailed> {
OidcLogin.complete(auth, p, "echolot://auth?error=access_denied&state=${p.state}")
}
assertTrue(e.message!!.contains("access_denied"))
}
@Test
fun refusesToStartWhenTheServerHasNoIdentityProvider() {
assertFailsWith<IllegalArgumentException> { OidcLogin.begin(AuthInfo(enabled = false)) }
}
}
+38 -6
View File
@@ -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}
}
+78
View File
@@ -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")
}
}
+32
View File
@@ -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()
+33
View File
@@ -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)
}
}
+20
View File
@@ -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()