Signing in and being allowed to administer the server were the same question: the OIDC callback refused a session outright to anyone outside the admin group. A legitimate user could authenticate, be told what they could not do, and be left with no way to see or delete the data their own devices had uploaded. They are separate questions now. Everyone who authenticates gets a session; the admin flag rides inside the MAC'd payload, so promoting yourself means forging a signature rather than editing a cookie, and a role that does not parse fails closed to "user". Pages scope themselves through visibleDevices/mayTouchRun rather than filtering individually — per-page scoping is what the next page added will be missing, and that failure is silent, since a listing that leaks other people's uploads looks exactly like one that does not. Someone else's run answers 404, not 403: a distinguishable refusal would confirm the run exists. Revoking devices and minting enrolment tokens affect the whole server and stay behind adminOnly at the route table, where someone looking for who-may-do-what will actually find it. Ownership is re-read per request instead of captured at sign-in, so unlinking an account takes effect immediately rather than at session expiry. Tests cover that, plus the degenerate case of an empty subject, which must own nothing rather than everything with an empty account id. Also: attribute the ICMPv6 finding per network. It compared "is IPv6 configured anywhere on this device" against "did any network answer", which on a phone reports IPv6-is-broken about a network where IPv6 was never configured. network_ref is null on every test, so the probe now records per-network outcomes structurally rather than as prose a finding would have to parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Echolot app
The production Android client (spec). Native Kotlin + Jetpack Compose. Multi-module; built bottom-up from a verifiable protocol spine.
Modules
| Module | Type | Status |
|---|---|---|
core-protocol |
pure Kotlin/JVM | done — client half of probe-protocol.md, verified live against the server |
core-measurement |
pure Kotlin/JVM | planned — measurement-schema.md types |
core-probe |
Android lib | planned — app-tier probes, ported from echolot-prober |
core-shizuku |
Android lib | planned — dual-path executor (UserService + newProcess fallback) |
app |
Android app | planned — Compose UI |
core-protocol is deliberately Android-free so it builds and unit-tests on any JDK (no Android
SDK) and can run integration tests against a live server.
core-protocol
Implements the control plane (SPKI-pinned enrollment/profile/sessions via HttpsURLConnection —
Android-API-1 compatible, hostname verification off because trust is the pin), the HKDF-SHA256
session-key schedule, and the binary ELT1 UDP data plane (HMAC gate, ECHO + observation block,
MTU probe) — byte-compatible with the Go server.
./gradlew :core-protocol:test # unit tests (crypto vectors, wire round-trip)
scripts/test-fmr.sh # live end-to-end test against the deployed server
test-fmr.sh mints an enrollment token over SSH, enrolls via the public control plane, computes
the SPKI pin from the served cert, and runs LiveServerTest — proving the client speaks the wire
protocol to the real server (enroll → profile → session → echo+observation → MTU → observations).
The live test self-skips when ECHOLOT_LIVE_* env vars are absent, so unit runs and CI stay green
offline.