runs: scope by account; app: the PKCE half of signing in
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:
co-authored by
Claude Fable 5
parent
0eaba6150b
commit
4e6f2da3fb
@@ -184,6 +184,33 @@ class ControlClient(
|
|||||||
open("/v1/runs/$runId", "DELETE", credential).responseCode
|
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 {
|
fun observations(credential: String, sessionId: String): String {
|
||||||
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
||||||
val text = body(conn)
|
val text = body(conn)
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ data class CompatInfo(
|
|||||||
@SerialName("app_max") val appMax: String = "",
|
@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
|
@Serializable
|
||||||
data class Profile(
|
data class Profile(
|
||||||
@SerialName("profile_version") val profileVersion: Int = 0,
|
@SerialName("profile_version") val profileVersion: Int = 0,
|
||||||
@@ -86,6 +105,7 @@ data class Profile(
|
|||||||
val pins: List<String> = emptyList(),
|
val pins: List<String> = emptyList(),
|
||||||
val uploads: UploadPolicy = UploadPolicy(),
|
val uploads: UploadPolicy = UploadPolicy(),
|
||||||
val compat: CompatInfo = CompatInfo(),
|
val compat: CompatInfo = CompatInfo(),
|
||||||
|
val auth: AuthInfo = AuthInfo(),
|
||||||
) {
|
) {
|
||||||
fun supports(capability: String) = capability in capabilities
|
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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -755,11 +755,19 @@ func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
list := s.Runs.List(dev.ID)
|
list := s.Runs.ListFor(s.visibleDevices(dev))
|
||||||
if list == nil {
|
if list == nil {
|
||||||
list = []runs.Meta{}
|
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) {
|
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"})
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Scoped to the calling device's own directory: one device cannot read another's runs by
|
// Resolved against the caller's own devices only, so a run id from another account is not
|
||||||
// guessing a run id.
|
// found rather than being fetched from wherever it happens to live.
|
||||||
b, err := s.Runs.Get(dev.ID, r.PathValue("id"))
|
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 {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"})
|
||||||
return
|
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"})
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
|
||||||
return
|
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()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -928,3 +946,17 @@ func (s *Server) accountStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"device_id": dev.ID,
|
"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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,6 +209,38 @@ func (s *Store) List(deviceID string) []Meta {
|
|||||||
return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID)))
|
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.
|
// Get returns the stored document bytes for one run.
|
||||||
func (s *Store) Get(deviceID, runID string) ([]byte, error) {
|
func (s *Store) Get(deviceID, runID string) ([]byte, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,26 @@ func (s *Store) LinkAccount(deviceID, accountID, displayName string) error {
|
|||||||
return errors.New("no such device")
|
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.
|
// Devices returns a copy of the device list, for the admin UI.
|
||||||
func (s *Store) Devices() []Device {
|
func (s *Store) Devices() []Device {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user