Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e6f2da3fb |
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user