Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 ce6d0c2f64 oidc: the server becomes a relying party, and devices can carry an account
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 35s
server-release / release (push) Successful in 34s
Echolot delegates identity to whatever IdP the operator already runs and stores
no passwords - no hashing, no reset flow, no lockout policy, and no credential
database to lose. For a tool people self-host next to other services, that is
the difference between one more service and one more thing that can leak
someone's password.

Verification is stdlib-only, matching the server's no-dependency rule. Longer
than jwt.Parse, and auditable in one sitting. The part that matters is the
algorithm allow-list: taking `alg` from the token is the classic forgery, so it
is fixed in code. Tests cover the real attacks against a genuine signer - a
self-contained IdP with real keys, because a mock that returns success proves
nothing about a verifier:

  alg=none, HS256/RS256 confusion, a payload swapped under a valid signature,
  a token addressed to another client, a token from another issuer, expired
  and future-dated tokens, and discovery that renames the issuer (which would
  otherwise have us fetch a stranger's keys believing they were the provider's).

With no admin group configured nobody is an admin. An operator who has not said
who may administer the server has not thereby said "anyone who can log in".

Device and account stay separate concepts: enrollment admits a device (operator's
token), signing in attributes it to a person (POST /v1/account/link, device
credential plus ID token - both required, neither substitutes). uploads=account
now means what it says instead of refusing everyone, and signing in does not
override uploads=off.

The profile advertises the sign-in configuration so the app can offer the button
only when there is something behind it, and drive PKCE without anyone typing an
issuer URL. A discovery failure is reported rather than hidden, so "configured
but the provider is not answering" is distinguishable from "not configured".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:52:56 +02:00
mrambossekandClaude Fable 5 57a5ef8796 app: the stable-pseudonym switch was live at a level that pseudonymizes nothing
At `full` the anonymizer returns the document unchanged, so the salt has nothing
to act on - but the switch was enabled and looked like it did something. A
control that silently does nothing is the same class of fault as the preview
button and the archived-level label: the screen implying more than is true.

Shown disabled with the reason rather than hidden. The setting is still stored
and applies the moment the level changes, so making it vanish would hide state
that is still there; and a settings screen whose controls appear and disappear
as you touch other controls is harder to trust, not easier. The label dims with
the switch so "not active right now" reads at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:16:36 +02:00
mrambossekandClaude Fable 5 c19f382640 privacy: scrub identifiers inside raw shell output
Running the Shizuku tier for the first time uploaded every MAC address on the
local network to the server at the balanced level - fourteen of them, router and
all. The probes embed raw command output verbatim (ip neigh, ip route), which is
good evidence and also a complete household device inventory, and the anonymizer
could not see it: classification is by field name and whole-value shape, and
ip_neigh is one long string that is itself neither a MAC nor an address.

measurement-schema.md flagged raw dumps as hard to anonymize and proposed
dropping them from exports. Scrubbing is better: identifiers inside unclassified
strings are replaced in place with the same pseudonyms used elsewhere, so a MAC
appearing in both a parsed field and a raw dump still reads as one device, and
the dump stays readable - neighbour-table shape, host count, RFC1918 addresses
and vendor prefixes all survive. Dropping it would have protected the same data
by destroying the reason for collecting it.

One pass, not three: sequential passes re-process their own output. Once a MAC
became 78:9a:18:xx:yy:zz the IPv6 pattern matched it - six hex groups separated
by colons is an address - and destroyed the vendor prefix the MAC rule had just
preserved. Ordered alternation resolves each position once, MAC first.

RealDocumentTest runs the anonymizer over a captured run when ECHOLOT_REAL_RUN
points at one and fails on any surviving MAC; it self-skips otherwise so no
one's network lands in the repo. Against the document that leaked: 14 in, 0 out.

Also: the Settings preview button did nothing, reading UiState.history which is
empty until the History screen has been opened - same root cause as the "0
run(s)" count. It reads the archive now, and says when there is nothing to show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:12:13 +02:00
mrambossekandClaude Fable 5 d04babff51 engine: live test for upstream throughput
3125 sent, 3125 counted by the server, 0% loss. The assertion that earns its
keep is received <= sent: that is what catches a counter that was never reset
between runs, which would otherwise look like a suspiciously good result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 15:56:51 +02:00
17 changed files with 1598 additions and 321 deletions
+61
View File
@@ -941,3 +941,64 @@ Also: a `BackHandler` now returns from Settings/History to the run screen. The s
state variable with nothing connecting it to the back stack, so the system Back gesture left the state variable with nothing connecting it to the back stack, so the system Back gesture left the
app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run
screen. screen.
### Upstream throughput (server-v0.6.3, 2026-08-01)
The mirror of the downstream case: the client generates the traffic and the server counts it. No
grant is involved — the client is sending its own packets, so there is nothing to amplify — but it
does need the server's tally, because **only the far end knows how much arrived**. Without that
number a sender measures how fast it can *transmit*, which is usually just the speed of the local
NIC and is a different question from the one being asked.
`TYPE_THROUGHPUT_UP` (0x0F) is counted and deliberately **never answered**: a reply would double
the traffic and drag the return path into a measurement that is specifically about the outbound
one.
The tally is a counter, not a list, and short-circuits **before** the observation log. A
five-second run at 20 Mbps is around ten thousand packets; one struct each would turn a
measurement into an allocation storm on a shared server, and nothing needs the per-packet detail
since the client holds the send-side record. The gap between the two counts is the loss.
`direction=up` on the throughput action sends nothing — it zeroes the counter, so a second run in
one session measures itself rather than inheriting the first one's packets. The live test asserts
`received <= sent`, which is what catches a counter that was never reset.
Live against fmr: **3125 sent, 3125 counted, 0 % loss, 10.0 Mbit/s** at a 10 Mbit/s request, with
`measures_network: false` — correct, since what arrived matched what was offered, so the path was
never the constraint.
### Raw shell dumps leaked the whole LAN (2026-08-01)
Found by running the Shizuku shell tier for the first time. The tier works — `tiers.shizuku: true`,
`exec_path: UserService` (so the UserService binds on the OnePlus, as recorded), `runs_as
shell(2000)`, 7/7 commands — and the run promptly uploaded **every MAC address on the local
network** to fmr at the `balanced` level: router, phones, whatever else was on the wifi. Fourteen
of them.
The probes embed raw command output verbatim (`ip neigh`, `ip route`, `id`), which is genuinely
good evidence and also a complete household device inventory. The anonymizer could not see it:
classification is by field name and by whole-value shape, and `ip_neigh` is one long string that is
itself neither a MAC nor an address. measurement-schema.md §9 item 2 had flagged raw dumps as "hard
to anonymize" and proposed dropping them from exports; nothing enforced either.
**Scrubbing beats dropping.** Identifiers inside any unclassified string are now replaced in place,
using the same pseudonyms as everywhere else — so a MAC that appears both in a parsed field and in
a raw dump still reads as one device. The dump stays readable and auditable: you can still see the
neighbour table's shape, the host count, RFC1918 addresses and vendor prefixes. Dropping the
evidence would have protected the same data while destroying the reason for collecting it.
Two implementation notes worth keeping:
- **One pass, not three.** Sequential passes re-process their own output: once a MAC became
`78:9a:18:xx:yy:zz`, the IPv6 pattern matched it — six hex groups separated by colons *is* an
address — and destroyed the vendor prefix the MAC rule had just preserved. Ordered alternation
resolves each position once, MAC first.
- The patterns are conservative on purpose. A missed address gets caught by another rule or not at
all; an over-eager one mangles timestamps and version strings, corrupting evidence to protect
nothing.
`RealDocumentTest` runs the anonymizer over a captured run when `ECHOLOT_REAL_RUN` points at one,
and fails on any MAC that survives. It self-skips otherwise, so no one's network is committed to the
repo. Against the actual leaked document: **14 MACs in, 0 surviving.**
Also fixed: the Settings *Preview what an upload would send* button did nothing. It read
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
preview rather than silently ignoring the tap.
@@ -101,11 +101,10 @@ class MainActivity : ComponentActivity() {
onApplyRetention = vm::applyRetention, onApplyRetention = vm::applyRetention,
onDeleteAll = vm::deleteAllRuns, onDeleteAll = vm::deleteAllRuns,
onPreviewUpload = { onPreviewUpload = {
// Preview the newest run, since that is the one the user just made // Straight from the archive: the newest run is the one the user just
// and the one they are deciding about. // made and the one they are deciding about. Always shows something,
vm.state.history.firstOrNull()?.let { r -> // even when there is nothing to preview yet.
lifecycleScope.launch { preview = vm.uploadPreview(r.id) } lifecycleScope.launch { preview = vm.previewNewestRun() }
}
}, },
onCheckServer = vm::checkServer, onCheckServer = vm::checkServer,
onEnroll = vm::enroll, onEnroll = vm::enroll,
@@ -194,6 +194,21 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
store.read(id)?.let { store.redactedForUpload(it) } store.read(id)?.let { store.redactedForUpload(it) }
} }
/**
* Preview of the most recent run, read from the archive rather than from [UiState.history].
*
* The history list is only populated once the History screen has been opened, so a preview
* driven from it did nothing at all on a freshly-opened Settings screen — a button that
* silently does nothing is worse than one that says why.
*/
suspend fun previewNewestRun(): String = withContext(Dispatchers.IO) {
val newest = store.list().firstOrNull()
?: return@withContext "No archived runs yet. Run a measurement first, then this will " +
"show exactly what an upload would send."
store.read(newest.id)?.let { store.redactedForUpload(it) }
?: "That run could not be read back from the archive."
}
fun archivedBytes(): Long = store.totalBytes() fun archivedBytes(): Long = store.totalBytes()
/** Counted from the archive itself, not from [UiState.history], which is empty until the /** Counted from the archive itself, not from [UiState.history], which is empty until the
@@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -130,12 +131,21 @@ fun SettingsScreen(
} }
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall) Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
// At FULL nothing is pseudonymized, so a salt has nothing to act on. Shown
// disabled rather than hidden: the setting is still stored and still applies the
// moment the level changes, and a control that vanishes hides that fact.
Toggle( Toggle(
label = "Stable pseudonyms across runs", label = "Stable pseudonyms across runs",
detail = "Lets you compare uploaded runs over time (same SSID reads the same " + detail = if (privacy == PrivacyLevel.FULL) {
"each time). It also links your uploads together, so leave it off on a " + "Not used at this level — nothing is pseudonymized, so there is nothing " +
"server you don't run yourself.", "to keep stable. Choose balanced or strict to use this."
checked = stableSalt, } else {
"Lets you compare uploaded runs over time (same SSID reads the same " +
"each time). It also links your uploads together, so leave it off on " +
"a server you don't run yourself."
},
checked = stableSalt && privacy != PrivacyLevel.FULL,
enabled = privacy != PrivacyLevel.FULL,
) { stableSalt = it; settings.stableSalt = it } ) { stableSalt = it; settings.stableSalt = it }
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") } TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
@@ -237,13 +247,22 @@ private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
} }
@Composable @Composable
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) { private fun Toggle(
label: String,
detail: String,
checked: Boolean,
enabled: Boolean = true,
onChange: (Boolean) -> Unit,
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium) // Dimmed together with the switch, so "this does nothing right now" reads at a glance
Text(detail, style = MaterialTheme.typography.bodySmall) // instead of only on close inspection.
val alpha = if (enabled) 1f else 0.5f
Text(label, style = MaterialTheme.typography.bodyMedium, color = LocalContentColor.current.copy(alpha = alpha))
Text(detail, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(alpha = alpha))
} }
Switch(checked = checked, onCheckedChange = onChange) Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
} }
} }
@@ -65,4 +65,41 @@ class LiveThroughputTest {
assertTrue(m.contains(""""limited_by":"duration""""), assertTrue(m.contains(""""limited_by":"duration""""),
"the run did not end on the clock, so the rate measures the server, not the path: $m") "the run did not end on the clock, so the rate measures the server, not the path: $m")
} }
}
// Upstream is the direction only the far end can measure. The assertion that matters is that
// the server's count is present and plausible against what we sent — a test that only checked
// "we transmitted some Mbps" would pass against a server that counted nothing at all.
@Test
fun measuresUpstreamAgainstTheServersCount() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveThroughputTest(up) skipped"); return
}
val control = ControlClient(url, setOf(pin), "0.2.0")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val (test, findings) = ProbeSession(cred, session, host, port).use { ps ->
ps.echo()
ThroughputMeasurement(SystemIdSource()).runUpstream(
cred, session.sessionId, control, ps, sessionRef = "sess-1",
durationS = 3, kbps = 10_000,
)
}
control.deleteSession(cred, session.sessionId)
val m = assertNotNull(test.metrics).toString()
println("upstream: ${test.status} $m")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(TestStatus.OK, test.status, "the server counted nothing: $m")
val recv = Regex(""""received_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
val sent = Regex(""""sent_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
assertNotNull(recv); assertNotNull(sent)
assertTrue(sent > 100, "barely anything was sent, so the rate means nothing: $m")
assertTrue(recv > 0, "the server received none of $sent packets: $m")
// The counts should be close on a healthy path; wildly different means the two sides are
// counting different things rather than the network losing packets.
assertTrue(recv <= sent, "the server counted MORE than we sent — the counter is not being reset")
println("sent $sent, server saw $recv")
}
}
+5 -1
View File
@@ -20,4 +20,8 @@ kotlin {
} }
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() } tasks.test {
useJUnitPlatform()
// Opt-in: point this at a captured run to check the anonymizer against real data.
System.getenv("ECHOLOT_REAL_RUN")?.let { environment("ECHOLOT_REAL_RUN", it) }
}
@@ -1,276 +1,327 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// Package privacy implements the anonymization contract of measurement-schema.md §8. // Package privacy implements the anonymization contract of measurement-schema.md §8.
// //
// The threat model is specific. An engineer running their own server wants the full document — // The threat model is specific. An engineer running their own server wants the full document —
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's // SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a // server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
// filter: the output is still a valid measurement document with the same tests, metrics and // filter: the output is still a valid measurement document with the same tests, metrics and
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is // findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
// still answerable from pseudonyms alone. // still answerable from pseudonyms alone.
// //
// Two properties are load-bearing and are what the tests pin: // Two properties are load-bearing and are what the tests pin:
// - Consistency within a document: one input value always maps to one pseudonym, so // - Consistency within a document: one input value always maps to one pseudonym, so
// correlations inside a run survive. // correlations inside a run survive.
// - No consistency *across* documents unless the user asks for it: the salt is per-run by // - No consistency *across* documents unless the user asks for it: the salt is per-run by
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is // default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
// opt-in (`Salt.stable`) for people diffing their own history on their own server. // opt-in (`Salt.stable`) for people diffing their own history on their own server.
package app.echo_lot.privacy package app.echo_lot.privacy
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Locale import java.util.Locale
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */ /** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
enum class PrivacyLevel(val wire: String) { enum class PrivacyLevel(val wire: String) {
/** Nothing removed. The right choice for your own server. */ /** Nothing removed. The right choice for your own server. */
FULL("full"), FULL("full"),
/** /**
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive: * Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not * you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
* which MikroTik, on which SSID, next to whose Chromecast. * which MikroTik, on which SSID, next to whose Chromecast.
*/ */
BALANCED("balanced"), BALANCED("balanced"),
/** /**
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their * Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
* codes and severities but lose descriptions (which quote real names). What is left cannot * codes and severities but lose descriptions (which quote real names). What is left cannot
* identify a network, and is still enough for aggregate "how common is this fault" work. * identify a network, and is still enough for aggregate "how common is this fault" work.
*/ */
STRICT("strict"); STRICT("strict");
companion object { companion object {
fun fromWire(s: String?): PrivacyLevel = fun fromWire(s: String?): PrivacyLevel =
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
/** The stricter of two levels — used to honour a server's minimum. */ /** The stricter of two levels — used to honour a server's minimum. */
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
} }
} }
/** /**
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded * The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device. * twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you * A stable salt trades that away for cross-run diffing and is only appropriate on a server you
* own — the app makes that an explicit choice, not a default. * own — the app makes that an explicit choice, not a default.
*/ */
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) { class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
companion object { companion object {
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false) fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true) fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
} }
} }
/** /**
* Transforms a measurement document to [level]. * Transforms a measurement document to [level].
* *
* Field classification is by JSON key name, because the schema names things consistently * Field classification is by JSON key name, because the schema names things consistently
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by * (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
* reading one table. Anything unrecognized is treated as identifying when it is a string inside * reading one table. Anything unrecognized is treated as identifying when it is a string inside
* a known-sensitive container, and left alone otherwise — see [Classification]. * a known-sensitive container, and left alone otherwise — see [Classification].
*/ */
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) { class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
private val cache = HashMap<String, String>() private val cache = HashMap<String, String>()
fun anonymize(doc: JsonObject): JsonObject { fun anonymize(doc: JsonObject): JsonObject {
if (level == PrivacyLevel.FULL) return stamp(doc) if (level == PrivacyLevel.FULL) return stamp(doc)
val walked = walkObject(doc, path = emptyList()) val walked = walkObject(doc, path = emptyList())
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
return stamp(out) return stamp(out)
} }
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */ /** Records what was done, so a reader of the archived/uploaded document is never guessing. */
private fun stamp(doc: JsonObject): JsonObject { private fun stamp(doc: JsonObject): JsonObject {
val run = doc["run"]?.jsonObject ?: return doc val run = doc["run"]?.jsonObject ?: return doc
val privacy = buildJsonObject { val privacy = buildJsonObject {
put("anonymization", level.wire) put("anonymization", level.wire)
put("salt", if (salt.stable) "stable" else "per_run") put("salt", if (salt.stable) "stable" else "per_run")
} }
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy)))) return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
} }
// ---- the tree walk ------------------------------------------------------------------- // ---- the tree walk -------------------------------------------------------------------
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject { private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
for ((k, v) in obj) { for ((k, v) in obj) {
val childPath = path + k val childPath = path + k
when { when {
Classification.dropAtBalanced(childPath) -> Unit // omit entirely Classification.dropAtBalanced(childPath) -> Unit // omit entirely
else -> put(k, walk(k, v, childPath)) else -> put(k, walk(k, v, childPath))
} }
} }
} }
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) { private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
is JsonObject -> walkObject(v, path) is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) }) is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive -> is JsonPrimitive ->
if (v.isString) { if (v.isString) {
// Name first (it is precise), then shape (it is exhaustive). A field nobody // Name first (it is precise), then shape (it is exhaustive). A field nobody
// classified must not be a field that leaks. // classified must not be a field that leaks.
val type = Classification.typeOf(key, path) ?: Classification.inferFromValue(v.content) val type = Classification.typeOf(key, path) ?: Classification.inferFromValue(v.content)
JsonPrimitive(transform(type, v.content)) JsonPrimitive(transform(type, v.content))
} else { } else {
v v
} }
} }
private fun transform(type: LogicalType?, value: String): String = when (type) { private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value // Unclassified strings still get their *embedded* identifiers scrubbed. A whole-value
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) } // check cannot see them: raw shell output is one long string that is neither a MAC nor an
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value) // address, so it sailed through both the name table and the shape check carrying every
LogicalType.IP4 -> ip4(value) // MAC on the user's LAN.
LogicalType.IP6 -> ip6(value) null -> scrubEmbedded(value)
LogicalType.FQDN -> fqdn(value) LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.OPAQUE_ID -> "redacted" LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.FREETEXT -> "[removed: may contain identifying text]" LogicalType.IP4 -> ip4(value)
} LogicalType.IP6 -> ip6(value)
LogicalType.FQDN -> fqdn(value)
// ---- per-type transforms ------------------------------------------------------------- LogicalType.OPAQUE_ID -> "redacted"
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
/** }
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT /**
* MikroTik" does not. * Replaces addresses and MACs found *inside* a longer string.
*/ *
private fun macPreservingOui(value: String): String { * Shizuku probes embed raw command output verbatim — `ip neigh`, `ip route`, `dumpsys` — which
val sep = if (value.contains('-')) '-' else ':' * is genuinely valuable evidence and also a complete inventory of every device on the user's
val parts = value.split(sep) * network, with hardware addresses. measurement-schema.md §9 flagged these as "hard to
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) } * anonymize" and proposed dropping them from exports.
val nic = pseudo("mac", value) { it } *
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6))) * Scrubbing beats dropping: the output stays readable and auditable — you can still see the
.joinToString(sep.toString()) * shape of the neighbour table and how many hosts there were — while the identifiers become
.lowercase(Locale.ROOT) * the same pseudonyms used everywhere else in the document. So a MAC appearing both in a
} * parsed field and in a raw dump still reads as one device.
*
/** * Only addresses and MACs are touched, for the same reason as [Classification.inferFromValue]:
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and * they are the patterns that cannot be mistaken for something else in free text.
* CGNAT addresses say something about the topology and nothing about the person, and a run */
* where 192.168.1.1 became a random public address would be actively misleading to read. private fun scrubEmbedded(value: String): String {
* Public addresses keep only their /16 so the network is still locatable at ISP granularity. // Cheap bail-out: the overwhelming majority of strings are short and contain neither.
*/ if (value.length < 7 || (!value.contains(':') && !value.contains('.'))) return value
private fun ip4(value: String): String { // One pass, not three. Sequential passes re-process their own output: after a MAC became
// A route destination carries a prefix length; pseudonymize the address and put it back, // 78:9a:18:xx:yy:zz the IPv6 pattern matched it — six hex groups separated by colons is
// or "0.0.0.0/0" turns into nonsense and the routing table becomes unreadable. // exactly an address — and mangled the vendor prefix that the MAC rule had just taken
value.substringAfter('/', "").takeIf { it.isNotEmpty() && value.contains('/') }?.let { len -> // care to preserve. Ordered alternation resolves each position once, MAC first.
return ip4(value.substringBefore('/')) + "/" + len return EMBEDDED.replace(value) { m ->
} when {
val o = value.split(".") m.groups[1] != null -> macPreservingOui(m.value)
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value m.groups[2] != null -> ip6(m.value)
val n = o.map { it.toInt() } else -> ip4(m.value)
val reserved = n[0] == 10 || }
(n[0] == 172 && n[1] in 16..31) || }
(n[0] == 192 && n[1] == 168) || }
(n[0] == 169 && n[1] == 254) ||
(n[0] == 100 && n[1] in 64..127) || // ---- per-type transforms -------------------------------------------------------------
n[0] == 127 || n[0] == 0 || n[0] >= 224
if (reserved) return value /**
val h = pseudo("ip4", value) { it } * Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}" * diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
} * MikroTik" does not.
*/
/** private fun macPreservingOui(value: String): String {
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in val sep = if (value.contains('-')) '-' else ':'
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that val parts = value.split(sep)
* is a device fingerprint, especially with EUI-64. if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
*/ val nic = pseudo("mac", value) { it }
private fun ip6(value: String): String { return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
// Dotted quads reach here through the family-agnostic field names (addr, gateway, dst); .joinToString(sep.toString())
// hand them to the IPv4 path rather than mangling them as if they were v6. .lowercase(Locale.ROOT)
if (value.count { it == ':' } < 2) return ip4(value) }
if (value.contains('/')) {
return ip6(value.substringBefore('/')) + "/" + value.substringAfter('/') /**
} * Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
val v = value.lowercase(Locale.ROOT) * CGNAT addresses say something about the topology and nothing about the person, and a run
// The unspecified address and the default route are not identities; mangling them would * where 192.168.1.1 became a random public address would be actively misleading to read.
// make a routing table unreadable for no privacy gain. * Public addresses keep only their /16 so the network is still locatable at ISP granularity.
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v */
private fun ip4(value: String): String {
// Unique local addresses (fc00::/7) need the *whole* prefix replaced, not the tail. // A route destination carries a prefix length; pseudonymize the address and put it back,
// // or "0.0.0.0/0" turns into nonsense and the routing table becomes unreadable.
// They look like the v6 equivalent of RFC1918, and the first instinct is to keep them for value.substringAfter('/', "").takeIf { it.isNotEmpty() && value.contains('/') }?.let { len ->
// the same reason: private, topological, says nothing about anyone. That reasoning does return ip4(value.substringBefore('/')) + "/" + len
// not carry over. An RFC1918 prefix is shared by millions of networks and identifies }
// none of them; a ULA global ID is 40 *random* bits, unique to one network by val o = value.split(".")
// construction (RFC 4193). It is a network fingerprint. Passing the leading groups if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
// through - which is what the general path does - leaked 32 of those 40 bits. val n = o.map { it.toInt() }
// val reserved = n[0] == 10 ||
// The prefix is pseudonymized as a unit, so two addresses on the same ULA subnet still (n[0] == 172 && n[1] in 16..31) ||
// land on the same pseudonymous prefix. "These hosts are on one network" survives; (n[0] == 192 && n[1] == 168) ||
// "this is *that* network" does not. (n[0] == 169 && n[1] == 254) ||
if (v.startsWith("fc") || v.startsWith("fd")) { (n[0] == 100 && n[1] in 64..127) ||
val groups = v.substringBefore('%').split(":") n[0] == 127 || n[0] == 0 || n[0] >= 224
val prefix = pseudo("ula-prefix", groups.take(3).joinToString(":")) { it } if (reserved) return value
val host = pseudo("ula-host", v) { it } val h = pseudo("ip4", value) { it }
return "fd${prefix.substring(0, 2)}:${prefix.substring(2, 6)}:${prefix.substring(6, 10)}" + return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
"::${host.substring(0, 4)}" }
}
val groups = v.substringBefore('%').split(":") /**
if (groups.size < 3) return v * IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
val h = pseudo("ip6", value) { it } * the same allocation) and pseudonymizes the rest — the interface identifier is the part that
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}" * is a device fingerprint, especially with EUI-64.
} */
private fun ip6(value: String): String {
/** // Dotted quads reach here through the family-agnostic field names (addr, gateway, dst);
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local" // hand them to the IPv4 path rather than mangling them as if they were v6.
* or "…under example.com" survives without naming the host. The suffix list is deliberately if (value.count { it == ':' } < 2) return ip4(value)
* short: guessing wrong keeps *more* pseudonymized, never less. if (value.contains('/')) {
*/ return ip6(value.substringBefore('/')) + "/" + value.substringAfter('/')
private fun fqdn(value: String): String { }
if (value.isEmpty()) return value val v = value.lowercase(Locale.ROOT)
val trailing = value.endsWith(".") // The unspecified address and the default route are not identities; mangling them would
val labels = value.trimEnd('.').split(".") // make a routing table unreadable for no privacy gain.
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) } if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
val keep = if (labels.last() in publicSuffixes) 1 else 0
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } } // Unique local addresses (fc00::/7) need the *whole* prefix replaced, not the tail.
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else "" //
} // They look like the v6 equivalent of RFC1918, and the first instinct is to keep them for
// the same reason: private, topological, says nothing about anyone. That reasoning does
// ---- STRICT --------------------------------------------------------------------------- // not carry over. An RFC1918 prefix is shared by millions of networks and identifies
// none of them; a ULA global ID is 40 *random* bits, unique to one network by
/** // construction (RFC 4193). It is a network fingerprint. Passing the leading groups
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the // through - which is what the general path does - leaked 32 of those 40 bits.
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes //
* (it interpolates real names), networks go entirely. // The prefix is pseudonymized as a unit, so two addresses on the same ULA subnet still
*/ // land on the same pseudonymous prefix. "These hosts are on one network" survives;
private fun strip(doc: JsonObject): JsonObject = buildJsonObject { // "this is *that* network" does not.
for ((k, v) in doc) { if (v.startsWith("fc") || v.startsWith("fd")) {
when (k) { val groups = v.substringBefore('%').split(":")
"networks", "server_sessions" -> Unit val prefix = pseudo("ula-prefix", groups.take(3).joinToString(":")) { it }
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t -> val host = pseudo("ula-host", v) { it }
val o = t.jsonObject return "fd${prefix.substring(0, 2)}:${prefix.substring(2, 6)}:${prefix.substring(6, 10)}" +
JsonObject(o.filterKeys { it != "evidence" && it != "params" }) "::${host.substring(0, 4)}"
})) }
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f -> val groups = v.substringBefore('%').split(":")
val o = f.jsonObject if (groups.size < 3) return v
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" }) val h = pseudo("ip6", value) { it }
})) return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" })) }
else -> put(k, v)
} /**
} * Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
} * or "…under example.com" survives without naming the host. The suffix list is deliberately
* short: guessing wrong keeps *more* pseudonymized, never less.
// ---- pseudonym machinery --------------------------------------------------------------- */
private fun fqdn(value: String): String {
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */ if (value.isEmpty()) return value
private fun pseudo(domain: String, value: String, shape: (String) -> String): String = val trailing = value.endsWith(".")
cache.getOrPut("$domain$value") { val labels = value.trimEnd('.').split(".")
val md = MessageDigest.getInstance("SHA-256") if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
md.update(salt.bytes) val keep = if (labels.last() in publicSuffixes) 1 else 0
md.update(domain.toByteArray()) val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
md.update(0) return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
md.update(value.lowercase(Locale.ROOT).toByteArray()) }
shape(md.digest().joinToString("") { "%02x".format(it) })
} // ---- STRICT ---------------------------------------------------------------------------
private companion object { /**
val publicSuffixes = setOf( * STRICT keeps the shape of the document and the numbers, and nothing that quotes the
"local", "lan", "home", "internal", "arpa", * network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk", * (it interpolates real names), networks go entirely.
) */
} private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
} for ((k, v) in doc) {
when (k) {
"networks", "server_sessions" -> Unit
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
val o = t.jsonObject
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
}))
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
val o = f.jsonObject
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
}))
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
else -> put(k, v)
}
}
}
// ---- pseudonym machinery ---------------------------------------------------------------
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain\u0000$value") {
val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes)
md.update(domain.toByteArray())
md.update(0)
md.update(value.lowercase(Locale.ROOT).toByteArray())
shape(md.digest().joinToString("") { "%02x".format(it) })
}
private companion object {
/**
* MAC | IPv6 | IPv4, in that order — alternation is ordered, so a MAC-shaped token is
* claimed by the MAC rule before the IPv6 rule can see it.
*
* The patterns are deliberately conservative. A missed address is scrubbed by another
* rule or not at all; an over-eager one mangles timestamps, version strings and log
* prefixes, corrupting evidence to protect nothing.
*/
val EMBEDDED = Regex(
// Raw strings: a regex written with escaped escapes is a regex nobody can check.
"""(\b[0-9a-fA-F]{2}(?:[:-][0-9a-fA-F]{2}){5}\b)""" +
"""|(\b(?:[0-9a-fA-F]{1,4}:){2,7}(?::|[0-9a-fA-F]{1,4})(?:[0-9a-fA-F:]*))""" +
"""|(\b(?:\d{1,3}\.){3}\d{1,3}\b)"""
)
val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
)
}
}
@@ -4,8 +4,12 @@
package app.echo_lot.privacy package app.echo_lot.privacy
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
@@ -110,4 +114,64 @@ class LeakTest {
assertTrue(out.contains("192.168.1.1"), "RFC1918 gateway should survive: $out") assertTrue(out.contains("192.168.1.1"), "RFC1918 gateway should survive: $out")
assertTrue(out.contains("192.168.1.44"), "RFC1918 interface address should survive: $out") assertTrue(out.contains("192.168.1.44"), "RFC1918 interface address should survive: $out")
} }
}
/**
* Raw shell output embeds a complete inventory of the local network, and neither the field-name
* table nor the whole-value shape check can see it: `ip_neigh` is one long string that is
* itself neither a MAC nor an address.
*
* This is not hypothetical. The blob below is (abridged) real output that reached the server
* at the `balanced` level from a test device, carrying the hardware address of every host on
* the network. measurement-schema.md §9 had flagged raw dumps as "hard to anonymize"; nothing
* enforced it.
*/
@Test
fun identifiersInsideRawShellOutputAreScrubbed() {
// Joined rather than written with escapes, so the fixture stays readable and there is no
// chance of an escape being mangled on its way into the JSON below.
val dump = listOf(
"uid=2000",
"10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 REACHABLE",
"10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE",
"10.13.102.111 dev wlan0 lladdr dc:a2:66:08:69:95 STALE",
"2001:4bb8:46a:e724:289d:87ff:feb6:ebd3 dev wlan0 lladdr b8:be:f4:bc:ca:cf STALE",
).joinToString(" | ")
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"tests":[{"id":"t","type":"link.ip_monitor",
"evidence":{"ip_neigh":"$dump"}}]}"""
).jsonObject
val out = json.encodeToString(
kotlinx.serialization.json.JsonObject.serializer(),
Anonymizer(PrivacyLevel.BALANCED, salt).anonymize(doc),
)
for (mac in listOf("90:09:d0:1a:83:e4", "78:9a:18:54:b8:f9", "dc:a2:66:08:69:95", "b8:be:f4:bc:ca:cf")) {
assertFalse(out.contains(mac), "a neighbour's MAC survived inside the raw dump: $mac")
}
assertFalse(out.contains("2001:4bb8:46a:e724:289d:87ff:feb6:ebd3"),
"a global IPv6 survived inside the raw dump")
// Scrubbed, not dropped: the evidence must still be readable, or the raw dump stops being
// evidence at all. Structure, hostnames of the fields, and RFC1918 addresses stay.
assertTrue(out.contains("REACHABLE") && out.contains("STALE"), "the dump lost its structure")
assertTrue(out.contains("10.13.102.1"), "RFC1918 addresses should stay readable: $out")
assertTrue(out.contains("78:9a:18"), "the vendor prefix should survive for identification")
}
// A MAC in a raw dump and the same MAC in a parsed field must land on the same pseudonym, or
// the document stops being internally consistent and one device reads as two.
@Test
fun theSameIdentifierMatchesAcrossParsedAndRawFields() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},
"networks":[{"wifi":{"bssid":"78:9a:18:54:b8:f9"}}],
"tests":[{"id":"t","evidence":{"ip_neigh":"gw dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE"}}]}"""
).jsonObject
val out = Anonymizer(PrivacyLevel.BALANCED, salt).anonymize(doc)
val parsed = out["networks"]!!.jsonArray[0].jsonObject["wifi"]!!.jsonObject["bssid"]!!
.jsonPrimitive.content
val raw = json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), out)
assertTrue(raw.contains(parsed),
"the parsed BSSID pseudonym ($parsed) does not appear in the scrubbed dump")
}
}
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Runs the anonymizer over a real captured document when one is supplied via ECHOLOT_REAL_RUN,
* and reports every MAC and public address that survives.
*
* Fixtures only contain the identifiers somebody thought to put in them. A real run off a real
* phone contains whatever the probes actually produce — which is how the raw-shell-output leak was
* found in the first place. Self-skips when no document is supplied, so nobody's network ends up
* committed to the repository.
*/
class RealDocumentTest {
@Test
fun noIdentifiersSurviveInARealDocument() {
val path = System.getenv("ECHOLOT_REAL_RUN")
if (path.isNullOrBlank() || !File(path).isFile) {
println("RealDocumentTest skipped (set ECHOLOT_REAL_RUN to a captured run)"); return
}
val json = Json { prettyPrint = false }
val doc = json.parseToJsonElement(File(path).readText()).jsonObject
val out = json.encodeToString(
JsonObject.serializer(),
Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 5 })).anonymize(doc),
)
val macs = Regex("""\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b""").findAll(out)
.map { it.value.lowercase() }
.filter { it != "00:00:00:00:00:00" }
.toSet()
val original = Regex("""\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b""")
.findAll(File(path).readText()).map { it.value.lowercase() }.toSet()
val survived = macs intersect original
println("MACs in the original: ${original.size}; unchanged after anonymizing: ${survived.size}")
assertTrue(survived.isEmpty(), "these real MAC addresses survived anonymization: $survived")
}
}
+23
View File
@@ -41,6 +41,7 @@ import (
"echo-lot.app/server/internal/config" "echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control" "echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/selftest" "echo-lot.app/server/internal/selftest"
"echo-lot.app/server/internal/selfupdate" "echo-lot.app/server/internal/selfupdate"
@@ -150,6 +151,27 @@ func serve(cfg *config.Config) error {
slog.Info("client compatibility", "accepts_app", appRange.String(), slog.Info("client compatibility", "accepts_app", appRange.String(),
"protocol", control.ProtocolVersion, "schema", control.SchemaVersion) "protocol", control.ProtocolVersion, "schema", control.SchemaVersion)
// Identity is optional. Without an issuer the server simply has no sign-in, and
// uploads=account can never be satisfied — which is the honest outcome, not a silent
// downgrade to anonymous.
var idp *oidc.Verifier
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
idp = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID,
AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
if cfg.OIDCAdminGroup == "" {
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
}
} else if cfg.UploadsMode == string(runs.ModeAccount) {
slog.Warn("uploads=account but no identity provider is configured — " +
"every upload will be refused")
}
ctl := &control.Server{ ctl := &control.Server{
Store: st, Sessions: sessions, Name: cfg.Name, Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
@@ -161,6 +183,7 @@ func serve(cfg *config.Config) error {
Runs: runStore, Runs: runStore,
AppRange: appRange, AppRange: appRange,
PublicControlURL: publicControlURL(cfg), PublicControlURL: publicControlURL(cfg),
OIDC: idp,
} }
// Left nil when there is no raw socket, so the handler answers "not implemented" with a // Left nil when there is no raw socket, so the handler answers "not implemented" with a
// reason rather than failing somewhere deeper. // reason rather than failing somewhere deeper.
+9
View File
@@ -67,6 +67,12 @@ type Config struct {
// first control listen address. // first control listen address.
PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying
// party and never stores passwords.
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
// Mode // Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
} }
@@ -115,6 +121,9 @@ func Load(args []string) (*Config, *Actions, error) {
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables") fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables") fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict") fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "OpenID Connect client id for this server")
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443") fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
+116 -1
View File
@@ -7,6 +7,7 @@
package control package control
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
@@ -27,6 +28,7 @@ import (
"echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/session" "echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store" "echo-lot.app/server/internal/store"
@@ -57,6 +59,8 @@ type Server struct {
// Granted server->client sends (spec §5). Both consume an asymmetric grant. // Granted server->client sends (spec §5). Both consume an asymmetric grant.
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error) DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error) BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
// OIDC verifies ID tokens when the operator has configured an issuer (may be nil).
OIDC *oidc.Verifier
// Runs stores uploaded measurement documents (may be nil: uploads unsupported). // Runs stores uploaded measurement documents (may be nil: uploads unsupported).
Runs *runs.Store Runs *runs.Store
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil: // FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
@@ -162,6 +166,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/runs", gate(s.listRuns)) mux.HandleFunc("GET /v1/runs", gate(s.listRuns))
mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun)) mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun))
mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun)) mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun))
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery) // TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
return mux return mux
} }
@@ -618,6 +625,10 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
// The app needs the upload rules before it offers the switch: whether uploads are // The app needs the upload rules before it offers the switch: whether uploads are
// accepted at all, and how much identifying detail it must strip first. // accepted at all, and how much identifying detail it must strip first.
"uploads": s.uploadPolicy(), "uploads": s.uploadPolicy(),
// What a client needs to start a sign-in, without hard-coding the operator's IdP into
// the app: where to authorize, which client id to use, and whether it is worth offering
// sign-in at all on this server.
"auth": s.authInfo(r.Context()),
// What this build speaks, and which app versions it will serve. A client checks the // What this build speaks, and which app versions it will serve. A client checks the
// server side of the same question against its own bounds. // server side of the same question against its own bounds.
"compat": map[string]any{ "compat": map[string]any{
@@ -714,7 +725,7 @@ func (s *Server) uploadRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"})
return return
} }
meta, err := s.Runs.Put(dev.ID, body) meta, err := s.Runs.Put(dev.ID, body, dev.LinkedToAccount())
switch { switch {
case err == nil: case err == nil:
slog.Info("run uploaded", "device", dev.ID, "run", meta.ID, slog.Info("run uploaded", "device", dev.ID, "run", meta.ID,
@@ -808,3 +819,107 @@ func upstreamJSON(sess *session.Session) map[string]any {
"span_ms": u.SpanMs(), "kbps": u.Kbps(), "span_ms": u.SpanMs(), "kbps": u.Kbps(),
} }
} }
// authInfo advertises the sign-in configuration, so the app can present a Sign in button only
// when there is something behind it, and can drive the flow without the user typing an issuer URL.
func (s *Server) authInfo(ctx context.Context) map[string]any {
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
return map[string]any{"enabled": false}
}
cfg := s.OIDC.Config()
out := map[string]any{
"enabled": true,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
// The app is a public client on a phone: no secret can be kept, so PKCE is what
// protects the code exchange (RFC 7636), and the redirect comes back through the
// scheme the app already registers for enrollment links.
"flow": "authorization_code+pkce",
"redirect_uri": "echolot://auth",
"scopes": "openid profile email",
}
if d, err := s.OIDC.Discover(ctx); err == nil {
out["authorization_endpoint"] = d.AuthorizationEndpoint
out["token_endpoint"] = d.TokenEndpoint
out["end_session_endpoint"] = d.EndSessionEndpoint
} else {
// Reported rather than hidden: an unreachable IdP is the operator's problem to see, and
// a client that knows the difference can say "sign-in is configured but the provider is
// not answering" instead of failing obscurely.
out["discovery_error"] = err.Error()
}
return out
}
// linkAccount ties the calling device to the person whose ID token it presents.
//
// The device credential proves *which device*; the ID token proves *which person*. Both are
// required, and neither substitutes for the other: enrollment admits a device to the server,
// signing in attributes it to someone.
func (s *Server) linkAccount(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "this server has no identity provider configured, so there is nothing to sign in to",
})
return
}
var body struct {
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.IDToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected an id_token"})
return
}
claims, err := s.OIDC.Verify(r.Context(), body.IDToken)
if err != nil {
// Deliberately terse to the client and detailed in the log: a caller probing token
// handling should not be told which check it failed.
slog.Info("rejected sign-in", "device", dev.ID, "err", err)
writeJSON(w, http.StatusForbidden, map[string]string{"error": "the identity token was not accepted"})
return
}
if err := s.Store.LinkAccount(dev.ID, claims.AccountID(), claims.Display()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
slog.Info("device linked to account", "device", dev.ID, "account", claims.AccountID(),
"admin", s.OIDC.IsAdmin(claims))
writeJSON(w, http.StatusOK, map[string]any{
"account_id": claims.AccountID(), "display_name": claims.Display(),
"admin": s.OIDC.IsAdmin(claims),
})
}
// unlinkAccount signs out on this device. The device stays enrolled: signing out should not cost
// someone their enrollment, which an operator had to grant.
func (s *Server) unlinkAccount(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if err := s.Store.LinkAccount(dev.ID, "", ""); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) accountStatus(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"signed_in": dev.LinkedToAccount(),
"account_id": dev.AccountID,
"display_name": dev.AccountName,
"device_id": dev.ID,
})
}
+475
View File
@@ -0,0 +1,475 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package oidc verifies OpenID Connect ID tokens against a configured issuer.
//
// Echolot is a *relying party*, never an identity provider. It delegates to whatever IdP the
// operator already runs and stores no passwords — no hashing, no reset flow, no lockout policy,
// and no credential database to leak. For a tool people self-host on a box they also use for
// other things, that is the difference between "one more service" and "one more thing that can
// lose your users' passwords".
//
// Verification is written against the stdlib rather than a JWT library, because the server has no
// external dependencies by design. That is a real constraint and it cuts both ways: the code below
// is longer than `jwt.Parse`, but it is also auditable in one sitting and cannot be broken by
// somebody else's release. The algorithm allow-list is the part that matters — accepting `alg`
// from the token itself is the classic JWT forgery, so it is fixed here and `none` can never
// appear.
package oidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
)
// Claims are the parts of an ID token Echolot acts on.
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience audience `json:"aud"`
Expiry int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Nonce string `json:"nonce"`
Email string `json:"email"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// AccountID is the stable identity of a person: issuer plus subject.
//
// Subject alone is not enough — it is only unique within an issuer — and email is not stable,
// since people change them and IdPs allow reuse. Keying on iss+sub means an operator can switch
// IdPs and know that the accounts did not silently merge.
func (c Claims) AccountID() string { return c.Issuer + "#" + c.Subject }
// Display is the friendliest name available, for the admin UI.
func (c Claims) Display() string {
for _, s := range []string{c.Name, c.Username, c.Email} {
if s != "" {
return s
}
}
return c.Subject
}
// audience tolerates the spec's two shapes: a string or an array of strings.
type audience []string
func (a *audience) UnmarshalJSON(b []byte) error {
var one string
if err := json.Unmarshal(b, &one); err == nil {
*a = audience{one}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return err
}
*a = many
return nil
}
func (a audience) contains(s string) bool {
for _, v := range a {
if v == s {
return true
}
}
return false
}
// Config is what the operator supplies.
type Config struct {
// Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/
Issuer string
// ClientID is this server's registered client. Tokens must be addressed to it.
ClientID string
// AdminGroup, when set, is the group claim a person must hold to reach the admin UI.
// Empty means no one is an admin via OIDC, which is the safe default: an operator who has
// not said who may administer the server has not said "everyone".
AdminGroup string
// Skew tolerated on exp/iat, for ordinary clock drift between the IdP and this server.
Skew time.Duration
}
func (c Config) Enabled() bool { return c.Issuer != "" && c.ClientID != "" }
// Discovery is the subset of the provider metadata document that is used.
type Discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
// Verifier fetches provider metadata and keys, and checks tokens against them.
type Verifier struct {
cfg Config
client *http.Client
mu sync.RWMutex
discovery *Discovery
keys map[string]crypto.PublicKey
keysAt time.Time
}
func New(cfg Config, client *http.Client) *Verifier {
if cfg.Skew == 0 {
cfg.Skew = 2 * time.Minute
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Verifier{cfg: cfg, client: client, keys: map[string]crypto.PublicKey{}}
}
func (v *Verifier) Config() Config { return v.cfg }
var (
ErrDisabled = errors.New("no OIDC issuer is configured on this server")
ErrMalformed = errors.New("token is not a well-formed JWT")
ErrSignature = errors.New("token signature does not verify")
ErrClaims = errors.New("token claims are not acceptable")
)
// Discover fetches (and caches) the provider metadata.
func (v *Verifier) Discover(ctx context.Context) (*Discovery, error) {
if !v.cfg.Enabled() {
return nil, ErrDisabled
}
v.mu.RLock()
d := v.discovery
v.mu.RUnlock()
if d != nil {
return d, nil
}
url := strings.TrimRight(v.cfg.Issuer, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := v.client.Do(req)
if err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("discovery: %s returned %d", url, resp.StatusCode)
}
var got Discovery
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
// The issuer in the document must match the one configured, or a redirect could point us at
// somebody else's keys while we keep believing we are talking to the configured provider.
if strings.TrimRight(got.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") {
return nil, fmt.Errorf("discovery: document says issuer %q, configured %q", got.Issuer, v.cfg.Issuer)
}
v.mu.Lock()
v.discovery = &got
v.mu.Unlock()
return &got, nil
}
// jwksTTL is how long keys are trusted before refetching. Short enough to pick up a rotation
// without an operator restarting anything; long enough that token checks are not IdP round trips.
const jwksTTL = 15 * time.Minute
func (v *Verifier) keyFor(ctx context.Context, kid string) (crypto.PublicKey, error) {
v.mu.RLock()
k, ok := v.keys[kid]
fresh := time.Since(v.keysAt) < jwksTTL
v.mu.RUnlock()
if ok && fresh {
return k, nil
}
if err := v.refreshKeys(ctx); err != nil {
return nil, err
}
v.mu.RLock()
defer v.mu.RUnlock()
if k, ok := v.keys[kid]; ok {
return k, nil
}
// A kid we have never seen, after a refresh, is a token from somewhere else.
return nil, fmt.Errorf("%w: no key %q at the issuer", ErrSignature, kid)
}
func (v *Verifier) refreshKeys(ctx context.Context) error {
d, err := v.Discover(ctx)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.JWKSURI, nil)
if err != nil {
return err
}
resp, err := v.client.Do(req)
if err != nil {
return fmt.Errorf("jwks: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks: %s returned %d", d.JWKSURI, resp.StatusCode)
}
var set struct {
Keys []jwk `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
return fmt.Errorf("jwks: %w", err)
}
parsed := make(map[string]crypto.PublicKey, len(set.Keys))
for _, k := range set.Keys {
if pub, err := k.publicKey(); err == nil {
parsed[k.Kid] = pub
}
}
if len(parsed) == 0 {
return errors.New("jwks: no usable keys")
}
v.mu.Lock()
v.keys = parsed
v.keysAt = time.Now()
v.mu.Unlock()
return nil
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
func (k jwk) publicKey() (crypto.PublicKey, error) {
switch k.Kty {
case "RSA":
n, err := b64uint(k.N)
if err != nil {
return nil, err
}
e, err := b64uint(k.E)
if err != nil {
return nil, err
}
if !e.IsInt64() || e.Int64() > 1<<31 {
return nil, errors.New("implausible RSA exponent")
}
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
case "EC":
curve, err := curveFor(k.Crv)
if err != nil {
return nil, err
}
x, err := b64uint(k.X)
if err != nil {
return nil, err
}
y, err := b64uint(k.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
}
return nil, fmt.Errorf("unsupported key type %q", k.Kty)
}
// Verify checks a serialized ID token and returns its claims.
//
// The order is deliberate: structure, then algorithm, then signature, then claims. Nothing about
// the token's contents is believed before its signature has been checked — reading `iss` or `aud`
// out of an unverified token and acting on it is how "verified" tokens turn out not to be.
func (v *Verifier) Verify(ctx context.Context, token string) (*Claims, error) {
if !v.cfg.Enabled() {
return nil, ErrDisabled
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrMalformed
}
headerJSON, err := b64(parts[0])
if err != nil {
return nil, ErrMalformed
}
var hdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(headerJSON, &hdr); err != nil {
return nil, ErrMalformed
}
// The allow-list is fixed here rather than taken from the token. Trusting the token's own
// `alg` is the classic JWT forgery: "none" turns any token into a valid one, and swapping RS256
// for HS256 lets an attacker sign with the public key. Neither is reachable from here.
if _, ok := allowedAlgs[hdr.Alg]; !ok {
return nil, fmt.Errorf("%w: algorithm %q is not accepted", ErrSignature, hdr.Alg)
}
pub, err := v.keyFor(ctx, hdr.Kid)
if err != nil {
return nil, err
}
sig, err := b64(parts[2])
if err != nil {
return nil, ErrMalformed
}
signed := parts[0] + "." + parts[1]
if err := verifySignature(hdr.Alg, pub, []byte(signed), sig); err != nil {
return nil, err
}
payload, err := b64(parts[1])
if err != nil {
return nil, ErrMalformed
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, ErrMalformed
}
if err := v.checkClaims(claims); err != nil {
return nil, err
}
return &claims, nil
}
func (v *Verifier) checkClaims(c Claims) error {
if strings.TrimRight(c.Issuer, "/") != strings.TrimRight(v.cfg.Issuer, "/") {
return fmt.Errorf("%w: issued by %q, expected %q", ErrClaims, c.Issuer, v.cfg.Issuer)
}
// A token addressed to a different client is a valid token that was not meant for us —
// accepting it lets any other client of the same IdP authenticate here.
if !c.Audience.contains(v.cfg.ClientID) {
return fmt.Errorf("%w: addressed to %v, not to %q", ErrClaims, []string(c.Audience), v.cfg.ClientID)
}
if c.Subject == "" {
return fmt.Errorf("%w: no subject", ErrClaims)
}
now := time.Now()
if c.Expiry == 0 || now.After(time.Unix(c.Expiry, 0).Add(v.cfg.Skew)) {
return fmt.Errorf("%w: expired", ErrClaims)
}
if c.IssuedAt != 0 && now.Add(v.cfg.Skew).Before(time.Unix(c.IssuedAt, 0)) {
return fmt.Errorf("%w: issued in the future", ErrClaims)
}
return nil
}
// IsAdmin reports whether these claims carry the configured admin group.
//
// With no group configured nobody is an admin: an operator who has not said who may administer
// the server has not thereby said "anyone who can log in".
func (v *Verifier) IsAdmin(c *Claims) bool {
if c == nil || v.cfg.AdminGroup == "" {
return false
}
for _, g := range c.Groups {
if g == v.cfg.AdminGroup {
return true
}
}
return false
}
var allowedAlgs = map[string]crypto.Hash{
"RS256": crypto.SHA256, "RS384": crypto.SHA384, "RS512": crypto.SHA512,
"ES256": crypto.SHA256, "ES384": crypto.SHA384, "ES512": crypto.SHA512,
}
func verifySignature(alg string, pub crypto.PublicKey, signed, sig []byte) error {
h := allowedAlgs[alg]
digest := hashOf(h, signed)
switch {
case strings.HasPrefix(alg, "RS"):
k, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("%w: %s token against a non-RSA key", ErrSignature, alg)
}
if err := rsa.VerifyPKCS1v15(k, h, digest, sig); err != nil {
return ErrSignature
}
return nil
case strings.HasPrefix(alg, "ES"):
k, ok := pub.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("%w: %s token against a non-EC key", ErrSignature, alg)
}
// JWS packs ECDSA signatures as r||s, fixed width — not the ASN.1 form ecdsa.Verify
// would otherwise expect.
if len(sig)%2 != 0 {
return ErrSignature
}
half := len(sig) / 2
r := new(big.Int).SetBytes(sig[:half])
s := new(big.Int).SetBytes(sig[half:])
if !ecdsa.Verify(k, digest, r, s) {
return ErrSignature
}
return nil
}
return ErrSignature
}
func hashOf(h crypto.Hash, b []byte) []byte {
switch h {
case crypto.SHA384:
d := sha512.Sum384(b)
return d[:]
case crypto.SHA512:
d := sha512.Sum512(b)
return d[:]
default:
d := sha256.Sum256(b)
return d[:]
}
}
func curveFor(crv string) (elliptic.Curve, error) {
switch crv {
case "P-256":
return elliptic.P256(), nil
case "P-384":
return elliptic.P384(), nil
case "P-521":
return elliptic.P521(), nil
}
return nil, fmt.Errorf("unsupported curve %q", crv)
}
// b64 decodes JWT base64url, which omits padding.
func b64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
func b64uint(s string) (*big.Int, error) {
b, err := b64(s)
if err != nil {
return nil, err
}
if len(b) == 0 {
return nil, errors.New("empty value")
}
return new(big.Int).SetBytes(b), nil
}
+288
View File
@@ -0,0 +1,288 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package oidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// A self-contained IdP: real keys, real signatures, real discovery and JWKS documents. Testing
// token verification against anything less than a genuine signer proves nothing — the failure
// modes that matter here (accepting `none`, accepting another client's token, accepting an
// expired one) all look fine to a mock that just returns success.
type testIdP struct {
*httptest.Server
rsaKey *rsa.PrivateKey
ecKey *ecdsa.PrivateKey
}
func newIdP(t *testing.T) *testIdP {
t.Helper()
rk, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
ek, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
idp := &testIdP{rsaKey: rk, ecKey: ek}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(Discovery{
Issuer: idp.URL,
AuthorizationEndpoint: idp.URL + "/auth",
TokenEndpoint: idp.URL + "/token",
JWKSURI: idp.URL + "/jwks",
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{
{
"kty": "RSA", "kid": "rsa-1", "alg": "RS256", "use": "sig",
"n": raw(rk.N.Bytes()),
"e": raw(big.NewInt(int64(rk.E)).Bytes()),
},
{
"kty": "EC", "kid": "ec-1", "alg": "ES256", "use": "sig", "crv": "P-256",
"x": raw(ek.X.Bytes()), "y": raw(ek.Y.Bytes()),
},
}})
})
idp.Server = httptest.NewServer(mux)
t.Cleanup(idp.Close)
return idp
}
func raw(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (i *testIdP) sign(t *testing.T, alg, kid string, claims map[string]any) string {
t.Helper()
h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"})
p, _ := json.Marshal(claims)
signing := raw(h) + "." + raw(p)
digest := sha256.Sum256([]byte(signing))
var sig []byte
switch alg {
case "RS256":
s, err := rsa.SignPKCS1v15(rand.Reader, i.rsaKey, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
sig = s
case "ES256":
r, s, err := ecdsa.Sign(rand.Reader, i.ecKey, digest[:])
if err != nil {
t.Fatal(err)
}
// JWS wants fixed-width r||s, not ASN.1.
sig = make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
default:
t.Fatalf("unsupported test alg %q", alg)
}
return signing + "." + raw(sig)
}
func (i *testIdP) claims(extra map[string]any) map[string]any {
c := map[string]any{
"iss": i.URL, "sub": "user-1", "aud": "echolot",
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
"email": "someone@example.net", "groups": []string{"users"},
}
for k, v := range extra {
c[k] = v
}
return c
}
func verifier(i *testIdP, adminGroup string) *Verifier {
return New(Config{Issuer: i.URL, ClientID: "echolot", AdminGroup: adminGroup}, i.Client())
}
func TestAcceptsAGenuineToken(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, tc := range []struct{ alg, kid string }{{"RS256", "rsa-1"}, {"ES256", "ec-1"}} {
got, err := v.Verify(context.Background(), idp.sign(t, tc.alg, tc.kid, idp.claims(nil)))
if err != nil {
t.Fatalf("%s: %v", tc.alg, err)
}
if got.Subject != "user-1" || got.Email != "someone@example.net" {
t.Fatalf("%s: claims not parsed: %+v", tc.alg, got)
}
if want := idp.URL + "#user-1"; got.AccountID() != want {
t.Errorf("AccountID = %q, want %q", got.AccountID(), want)
}
}
}
// "alg": "none" is the oldest JWT forgery there is: strip the signature, declare no algorithm,
// and a naive verifier accepts anything. It must not even reach the key lookup.
func TestRejectsAlgNone(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
h, _ := json.Marshal(map[string]string{"alg": "none", "kid": "rsa-1", "typ": "JWT"})
p, _ := json.Marshal(idp.claims(nil))
token := raw(h) + "." + raw(p) + "."
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("alg=none was not refused as a signature failure: %v", err)
}
}
// The other classic: declare HS256 so the verifier treats the RSA *public* key as an HMAC secret,
// which the attacker also has. The allow-list has no symmetric algorithms at all.
func TestRejectsSymmetricAlgorithmConfusion(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
h, _ := json.Marshal(map[string]string{"alg": "HS256", "kid": "rsa-1", "typ": "JWT"})
p, _ := json.Marshal(idp.claims(nil))
token := raw(h) + "." + raw(p) + "." + raw([]byte("whatever"))
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("HS256 confusion was not refused: %v", err)
}
}
func TestRejectsATamperedPayload(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
good := idp.sign(t, "RS256", "rsa-1", idp.claims(nil))
// Swap the payload for one claiming to be somebody else, keeping the valid signature.
forged, _ := json.Marshal(idp.claims(map[string]any{"sub": "admin"}))
parts := []byte(good)
dot1, dot2 := 0, 0
for i, c := range parts {
if c == '.' {
if dot1 == 0 {
dot1 = i
} else {
dot2 = i
}
}
}
token := string(parts[:dot1+1]) + raw(forged) + string(parts[dot2:])
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrSignature) {
t.Fatalf("a swapped payload was not refused: %v", err)
}
}
// A token from the same IdP but issued to a different client is perfectly valid — just not for
// us. Accepting it would let any other client of the same provider authenticate here.
func TestRejectsAnotherClientsToken(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "some-other-app"}))
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) {
t.Fatalf("another client's token was accepted: %v", err)
}
}
func TestAcceptsAudienceArrayContainingUs(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": []string{"other", "echolot"}}))
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Fatalf("an audience array including us was refused: %v", err)
}
}
func TestRejectsExpiredAndFutureTokens(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
expired := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{
"exp": time.Now().Add(-time.Hour).Unix(),
}))
if _, err := v.Verify(context.Background(), expired); !errors.Is(err, ErrClaims) {
t.Errorf("expired token accepted: %v", err)
}
future := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{
"iat": time.Now().Add(time.Hour).Unix(),
}))
if _, err := v.Verify(context.Background(), future); !errors.Is(err, ErrClaims) {
t.Errorf("token issued in the future accepted: %v", err)
}
}
// A token signed by a completely different provider, with its own keys and its own kid.
func TestRejectsATokenFromAnotherIssuer(t *testing.T) {
ours, theirs := newIdP(t), newIdP(t)
v := verifier(ours, "")
tok := theirs.sign(t, "RS256", "rsa-1", theirs.claims(nil))
if _, err := v.Verify(context.Background(), tok); err == nil {
t.Fatal("a token from another issuer was accepted")
}
}
func TestRejectsMalformedTokens(t *testing.T) {
idp := newIdP(t)
v := verifier(idp, "")
for _, bad := range []string{"", "not-a-token", "a.b", "a.b.c.d", "...", "!!!.???.***"} {
if _, err := v.Verify(context.Background(), bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
// With no admin group configured, nobody is an admin. An operator who has not said who may
// administer the server has not thereby said "anyone who can log in".
func TestNobodyIsAdminUntilAGroupIsConfigured(t *testing.T) {
idp := newIdP(t)
claims := &Claims{Groups: []string{"users", "echolot-admins"}}
if verifier(idp, "").IsAdmin(claims) {
t.Error("someone was an admin with no admin group configured")
}
if !verifier(idp, "echolot-admins").IsAdmin(claims) {
t.Error("a member of the configured group was not an admin")
}
if verifier(idp, "other-group").IsAdmin(claims) {
t.Error("a non-member was an admin")
}
if verifier(idp, "echolot-admins").IsAdmin(nil) {
t.Error("an absent identity was an admin")
}
}
// A discovery document whose issuer disagrees with the configured one means we were redirected
// somewhere — and would otherwise have fetched that somewhere's signing keys while believing
// they belonged to the configured provider.
func TestRefusesDiscoveryThatRenamesTheIssuer(t *testing.T) {
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
defer srv.Close()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(Discovery{Issuer: "https://somewhere.else", JWKSURI: srv.URL + "/jwks"})
})
v := New(Config{Issuer: srv.URL, ClientID: "echolot"}, srv.Client())
if _, err := v.Discover(context.Background()); err == nil {
t.Fatal("discovery accepted a document for a different issuer")
}
}
func TestDisabledWithoutConfiguration(t *testing.T) {
v := New(Config{}, nil)
if v.Config().Enabled() {
t.Fatal("an unconfigured verifier reports itself enabled")
}
if _, err := v.Verify(context.Background(), "x.y.z"); !errors.Is(err, ErrDisabled) {
t.Fatalf("want ErrDisabled, got %v", err)
}
}
+14 -10
View File
@@ -38,10 +38,9 @@ const (
// ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already // ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already
// required an admin-minted token, so "anyone enrolled" is not "anyone". // required an admin-minted token, so "anyone enrolled" is not "anyone".
ModeAnonymous Mode = "anonymous" ModeAnonymous Mode = "anonymous"
// ModeAccount accepts uploads only from a device tied to a signed-in account. The account // ModeAccount accepts uploads only from a device where somebody has signed in (see
// system (OIDC) is not built yet, so today this refuses everything with a distinct reason — // /v1/account/link). Enrollment alone is not enough: the operator's token admits a device,
// it exists so operators can pick the strict setting now and have it mean the right thing // an account attributes it to a person.
// when accounts land, rather than silently loosening on upgrade.
ModeAccount Mode = "account" ModeAccount Mode = "account"
) )
@@ -120,22 +119,27 @@ func Open(stateDir string, p Policy) (*Store, error) {
func (s *Store) Policy() Policy { return s.policy } func (s *Store) Policy() Policy { return s.policy }
// Accepts reports whether an upload would be allowed at all, so callers can answer the // Accepts reports whether an upload from this caller would be allowed at all, so callers can
// capability question without a body. // answer the capability question without a body.
func (s *Store) Accepts() error { //
// linked says whether a person has signed in on the uploading device. It is the only thing that
// distinguishes ModeAccount from ModeOff — and the reason the check takes an argument at all.
func (s *Store) Accepts(linked bool) error {
switch s.policy.Mode { switch s.policy.Mode {
case ModeOff: case ModeOff:
return ErrDisabled return ErrDisabled
case ModeAccount: case ModeAccount:
return ErrNeedAccount if !linked {
return ErrNeedAccount
}
} }
return nil return nil
} }
// Put validates and stores one uploaded document. body is the raw JSON as received: it is stored // Put validates and stores one uploaded document. body is the raw JSON as received: it is stored
// byte-for-byte so what the device signed off on is what sits on disk. // byte-for-byte so what the device signed off on is what sits on disk.
func (s *Store) Put(deviceID string, body []byte) (Meta, error) { func (s *Store) Put(deviceID string, body []byte, linked bool) (Meta, error) {
if err := s.Accepts(); err != nil { if err := s.Accepts(linked); err != nil {
return Meta{}, err return Meta{}, err
} }
if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes { if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes {
+32 -18
View File
@@ -34,19 +34,33 @@ func TestModeOffRefusesEverything(t *testing.T) {
p := DefaultPolicy() p := DefaultPolicy()
p.Mode = ModeOff p.Mode = ModeOff
s, _ := open(t, p) s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrDisabled) { if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrDisabled) {
t.Fatalf("want ErrDisabled, got %v", err) t.Fatalf("want ErrDisabled, got %v", err)
} }
} }
// ModeAccount must refuse today rather than fall back to anonymous: an operator who selects the // ModeAccount turns on whether the *caller* has signed in, and nothing else. A device that has
// strict setting before accounts exist must not be silently running the permissive one. // not is refused with a reason it can act on; one that has is treated exactly like anonymous mode.
func TestModeAccountRefusesUntilAccountsExist(t *testing.T) { func TestModeAccountTurnsOnWhetherTheCallerSignedIn(t *testing.T) {
p := DefaultPolicy() p := DefaultPolicy()
p.Mode = ModeAccount p.Mode = ModeAccount
s, _ := open(t, p) s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-1", AnonFull)); !errors.Is(err, ErrNeedAccount) {
t.Fatalf("want ErrNeedAccount, got %v", err) if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrNeedAccount) {
t.Fatalf("an un-signed-in device was not refused: %v", err)
}
if _, err := s.Put("dev1", doc("run-2", AnonFull), true); err != nil {
t.Fatalf("a signed-in device was refused: %v", err)
}
}
// Signing in must not open a door that the operator closed outright: mode=off means off.
func TestSigningInDoesNotOverrideModeOff(t *testing.T) {
p := DefaultPolicy()
p.Mode = ModeOff
s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-1", AnonFull), true); !errors.Is(err, ErrDisabled) {
t.Fatalf("a signed-in device uploaded to a server with uploads off: %v", err)
} }
} }
@@ -55,15 +69,15 @@ func TestMinAnonymizationEnforced(t *testing.T) {
p.MinAnonymization = AnonBalanced p.MinAnonymization = AnonBalanced
s, _ := open(t, p) s, _ := open(t, p)
if _, err := s.Put("dev1", doc("run-full", AnonFull)); !errors.Is(err, ErrNotAnonEnough) { if _, err := s.Put("dev1", doc("run-full", AnonFull), false); !errors.Is(err, ErrNotAnonEnough) {
t.Fatalf("full should be refused when balanced is required, got %v", err) t.Fatalf("full should be refused when balanced is required, got %v", err)
} }
// An undeclared level means nothing was stripped, so it must be treated as "full". // An undeclared level means nothing was stripped, so it must be treated as "full".
if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`)); !errors.Is(err, ErrNotAnonEnough) { if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`), false); !errors.Is(err, ErrNotAnonEnough) {
t.Fatalf("undeclared level should be treated as full, got %v", err) t.Fatalf("undeclared level should be treated as full, got %v", err)
} }
for _, lvl := range []string{AnonBalanced, AnonStrict} { for _, lvl := range []string{AnonBalanced, AnonStrict} {
if _, err := s.Put("dev1", doc("run-"+lvl, lvl)); err != nil { if _, err := s.Put("dev1", doc("run-"+lvl, lvl), false); err != nil {
t.Fatalf("%s should be accepted: %v", lvl, err) t.Fatalf("%s should be accepted: %v", lvl, err)
} }
} }
@@ -74,7 +88,7 @@ func TestSizeLimit(t *testing.T) {
p.MaxBytes = 200 p.MaxBytes = 200
s, _ := open(t, p) s, _ := open(t, p)
big := append(doc("run-1", AnonFull), make([]byte, 400)...) big := append(doc("run-1", AnonFull), make([]byte, 400)...)
if _, err := s.Put("dev1", big); !errors.Is(err, ErrTooLarge) { if _, err := s.Put("dev1", big, false); !errors.Is(err, ErrTooLarge) {
t.Fatalf("want ErrTooLarge, got %v", err) t.Fatalf("want ErrTooLarge, got %v", err)
} }
} }
@@ -84,7 +98,7 @@ func TestRetentionByCountKeepsNewest(t *testing.T) {
p.MaxRunsPerDevice = 3 p.MaxRunsPerDevice = 3
s, _ := open(t, p) s, _ := open(t, p)
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull)); err != nil { if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull), false); err != nil {
t.Fatalf("put %d: %v", i, err) t.Fatalf("put %d: %v", i, err)
} }
time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined
@@ -109,7 +123,7 @@ func TestRetentionByAge(t *testing.T) {
p.RetentionDays = 7 p.RetentionDays = 7
p.MaxRunsPerDevice = 0 p.MaxRunsPerDevice = 0
s, dir := open(t, p) s, dir := open(t, p)
if _, err := s.Put("dev1", doc("run-old", AnonFull)); err != nil { if _, err := s.Put("dev1", doc("run-old", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Backdate the index entry past the retention window. // Backdate the index entry past the retention window.
@@ -123,7 +137,7 @@ func TestRetentionByAge(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.Put("dev1", doc("run-new", AnonFull)); err != nil { if _, err := s.Put("dev1", doc("run-new", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := s.List("dev1") got := s.List("dev1")
@@ -136,7 +150,7 @@ func TestRetentionByAge(t *testing.T) {
// store directory or overwrite another device's data. // store directory or overwrite another device's data.
func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) { func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
s, dir := open(t, DefaultPolicy()) s, dir := open(t, DefaultPolicy())
if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull)); err != nil { if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull), false); err != nil {
t.Fatalf("put: %v", err) t.Fatalf("put: %v", err)
} }
var found []string var found []string
@@ -159,10 +173,10 @@ func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
func TestListIsPerDevice(t *testing.T) { func TestListIsPerDevice(t *testing.T) {
s, _ := open(t, DefaultPolicy()) s, _ := open(t, DefaultPolicy())
if _, err := s.Put("devA", doc("run-a", AnonFull)); err != nil { if _, err := s.Put("devA", doc("run-a", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.Put("devB", doc("run-b", AnonFull)); err != nil { if _, err := s.Put("devB", doc("run-b", AnonFull), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" { if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" {
@@ -175,7 +189,7 @@ func TestListIsPerDevice(t *testing.T) {
func TestMetaSummarisesTheDocument(t *testing.T) { func TestMetaSummarisesTheDocument(t *testing.T) {
s, _ := open(t, DefaultPolicy()) s, _ := open(t, DefaultPolicy())
m, err := s.Put("dev1", doc("run-1", AnonBalanced)) m, err := s.Put("dev1", doc("run-1", AnonBalanced), false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -190,7 +204,7 @@ func TestMetaSummarisesTheDocument(t *testing.T) {
func TestMalformedRejected(t *testing.T) { func TestMalformedRejected(t *testing.T) {
s, _ := open(t, DefaultPolicy()) s, _ := open(t, DefaultPolicy())
for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} { for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} {
if _, err := s.Put("dev1", body); !errors.Is(err, ErrMalformed) { if _, err := s.Put("dev1", body, false); !errors.Is(err, ErrMalformed) {
t.Fatalf("body %q: want ErrMalformed, got %v", body, err) t.Fatalf("body %q: want ErrMalformed, got %v", body, err)
} }
} }
+51
View File
@@ -37,8 +37,19 @@ type Device struct {
Credential string `json:"credential"` Credential string `json:"credential"`
Enrolled time.Time `json:"enrolled"` Enrolled time.Time `json:"enrolled"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// The account this device belongs to, as issuer#subject — empty when nobody has signed in
// on it. Enrollment and sign-in are deliberately separate: a device is admitted by an
// operator's token, and only later (if ever) associated with a person. Servers that accept
// anonymous uploads never need the second step.
AccountID string `json:"account_id,omitempty"`
AccountName string `json:"account_name,omitempty"`
LinkedAt time.Time `json:"linked_at,omitempty"`
} }
// LinkedToAccount reports whether a person has signed in on this device.
func (d Device) LinkedToAccount() bool { return d.AccountID != "" }
type Store struct { type Store struct {
mu sync.Mutex mu sync.Mutex
path string path string
@@ -126,6 +137,46 @@ func (s *Store) Redeem(token, name string) (*Device, error) {
} }
// DeviceByCredential authenticates a bearer credential. // DeviceByCredential authenticates a bearer credential.
// LinkAccount ties a device to a signed-in identity, or clears it when accountID is empty.
func (s *Store) LinkAccount(deviceID, accountID, displayName string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Devices {
if s.data.Devices[i].ID != deviceID {
continue
}
s.data.Devices[i].AccountID = accountID
s.data.Devices[i].AccountName = displayName
if accountID == "" {
s.data.Devices[i].LinkedAt = time.Time{}
} else {
s.data.Devices[i].LinkedAt = time.Now().UTC()
}
return s.save()
}
return errors.New("no such device")
}
// Devices returns a copy of the device list, for the admin UI.
func (s *Store) Devices() []Device {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Device(nil), s.data.Devices...)
}
// DeleteDevice revokes a device: its credential stops working immediately.
func (s *Store) DeleteDevice(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.Devices {
if s.data.Devices[i].ID == id {
s.data.Devices = append(s.data.Devices[:i], s.data.Devices[i+1:]...)
return s.save()
}
}
return errors.New("no such device")
}
func (s *Store) DeviceByCredential(cred string) *Device { func (s *Store) DeviceByCredential(cred string) *Device {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()