Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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