Commit Graph
80 Commits
Author SHA1 Message Date
mrambossekandClaude Fable 5 ad85f3bfcd enrollment: the server mints the §2.1 bootstrap link, the app consumes it
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
POST /admin/enroll-tokens now returns the whole link, not just the token:

  echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token>

The server is the only party that knows all three parts at once, and the part
an operator gets wrong by hand is the base64 pin — which does not fail loudly,
it just never matches, surfacing days later as an inscrutable TLS error. The
app takes the link from a paste or from an echolot:// deep link (QR scan), and
writes URL, pin and credential together or not at all.

One trap the tests pin: an unencoded "+" in a query string decodes to a space,
so a hand-assembled link arrives with a pin wrong by one character. Base64 has
no spaces, so they are restored — unambiguous, and it cannot damage a correctly
encoded pin.

Also fixes a spec divergence: §2.1 names the field device_credential and the
first implementation shipped "credential". Both are sent now and the client
prefers the spec's; the alias goes once nothing reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:22 +02:00
mrambossekandClaude Fable 5 33a6acb0bf compat: stop mangling refusal messages with HTML escapes
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
The server's 426 body reached the user as "needs \u003e= 0.2.0, \u003c 1.0.0":
Go escapes <, > and & by default for JSON destined for a page, which this is
not. Disabled at the encoder. The client now parses the error field rather than
pattern-matching it, so it survives whatever a future encoder decides to escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:41:00 +02:00
mrambossekandClaude Fable 5 9d6572bc33 compat: fix the too-new message's grammar, add a live gate test
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 30s
The generated refusal read "point at a app within range". Also adds
LiveCompatTest, which checks the half a unit test cannot reach: that two
independently-built artifacts agree on the window, that the profile stays
readable for a version the server refuses, and that both bounds are enforced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:38:45 +02:00
mrambossekandClaude Fable 5 0c5b021b63 compat: SemVer version windows between app and server
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:

  protocol_version  — CAN these builds talk. The correctness axis. Below 1.0.0
                      the minor is the breaking axis, per SemVer §4.
  release window    — MAY they, per policy. [min, max), advertised in the
                      profile, overridable by the operator.

The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.

Three rules that shape the rest:

  - GET /v1/profile is never gated. It is where a refused client learns which
    version it needs; gating it leaves the user with a network error instead of
    an answer, which is precisely the confusion this exists to remove.
  - An unparseable or absent version is "unknown", and is allowed. Development
    builds report "dev", and a client too old to send the header cannot be
    identified anyway.
  - Bounds sit at breaking boundaries, not at releases, so shipping a patch
    never requires editing a range. The app's server minimum is 0.4.2 for a
    stated reason: earlier multi-homed servers mis-addressed granted sends and
    the client measured 100% downstream loss that never happened.

The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:36:34 +02:00
mrambossekandClaude Fable 5 277e33da75 chore: drop core-measurement/bin from the index too
deploy-site / deploy (push) Failing after 1m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:48 +02:00
mrambossekandClaude Fable 5 14e5fad1b2 engine: downstream MTU and downstream train in the measurement document
Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).

ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.

Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:38 +02:00
mrambossekandClaude Fable 5 ce1aaa332a server: send granted traffic from the address the session actually used
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:45:43 +02:00
mrambossekandClaude Fable 5 7a94c9a3d7 chore: ignore the VSCodium Java extension's bin/ output
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:26:30 +02:00
mrambossekandClaude Fable 5 2521d39989 server: DF-mode big_send + uploaded-run storage with an operator policy
big_send now forces the Don't-Fragment bit for the whole burst by default, so
the largest size that arrives IS the downstream path MTU rather than "fragments
got through" — two different measurements the schema already separates. Sizes
above our own egress MTU (from the startup self-test) are refused up front and
reported as max_df_bytes, because absence caused by our kernel must not be read
as a limit of the client's path.

Uploads: one JSON file per run under the state dir, with the policy the operator
actually cares about — who may upload (off / anonymous / account), how large,
how long to keep, and the least anonymization accepted. The profile advertises
all of it so the app can present the switch honestly instead of discovering the
rules by failing. `account` refuses today rather than falling back to anonymous:
picking the strict setting before OIDC lands must not silently mean the loose one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:26:19 +02:00
mrambossekandClaude Opus 5 c75a9f5eb7 app: accurate Shizuku handoff — name the steps, add developer-options shortcut
Verified against Shizuku 13.6's manifest (pulled APK, aapt2 xmltree): its
wireless-debugging entry points (AdbPairingTutorialActivity,
AdbPairingService, StarterActivity) have no intent filters, so they are
not exported and cannot be launched externally; MainActivity answers only
MAIN/LAUNCHER with no deep link. Starting wireless debugging from another
app is therefore not possible, which is why the handoff lands on the
root-start screen.

Instead the hint now names the exact steps inside Shizuku ("Pairing", then
"Start"), and a second tap opens Developer options — that action IS public
and exported, and Wireless debugging has to be on before Shizuku's
wireless start works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:03:03 +02:00
mrambossekandClaude Opus 5 1d9e2063bf app: make the Shizuku banner actionable (open Shizuku / request permission)
A third-party app cannot start Shizuku — the wireless-debugging pairing
flow is privileged and lives in Shizuku's own app — so the banner
deep-links there when it is installed but stopped, and fires the
permission request directly when it is running but unauthorised. The hint
line says which.

Verified on-device together with the earlier UX work: progress bar showing
"test 4 of 8 · icmp.ping6 · ~33s left", Cancel beside the disabled Run
button, cutout-safe title, and the banner live-updating from
not-running to needs-permission via the binder listener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:58:39 +02:00
mrambossekandClaude Opus 5 d7dda40e4e app: tell the user before the run when Shizuku is installed but not started
Distinguishes not-installed (say nothing — don't nag users who don't use
Shizuku) from installed-but-stopped (amber banner: start it to include
shell-tier tests), plus running-unauthorised and ready. Detection is
listener-based since pingBinder() only becomes truthful once
ShizukuProvider delivers the binder; a launch-time poll would show a false
"not running". Installed-vs-not needs the <queries> entry on Android 11+.

Verified on-device: with shizuku_server stopped, the banner shows before
pressing Run; the title also now clears the status bar/cutout after the
safeDrawingPadding fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:51:01 +02:00
mrambossekandClaude Opus 5 217818f7b3 app: progress bar with ETA, cancel button, and cutout-safe layout
- Probe.estimatedMs (measured per probe; timeout-bound ones dominate)
  drives a determinate progress bar and "test N of M · ~Xs left",
  including the Shizuku battery in the total.
- Cancel stops the run and shows the partial results as a normal document
  (findings + verdict over what was collected) but never uploads them.
- safeDrawingPadding() on the root column: Android 15 is edge-to-edge by
  default and the title was colliding with the status-bar clock and the
  camera cutout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:48:18 +02:00
mrambossekandClaude Opus 5 2e34463c0a app: router identification verified on-device — named a MikroTik RouterOS 7.23.2
The probe identified the LAN's IPv6 RA sender end to end from an
unprivileged app: EUI-64 MAC recovery (78:9A:18:54:B8:F9, matching the
Shizuku neighbor table) -> MikroTik by OUI, corroborated by the UPnP
device description (RouterOS/7.23.2, MikroTik Router) and reverse DNS
(router.hudelist.local). Cellular's RFC 7217 privacy RA source is
correctly reported as not-EUI-64 instead of guessed. The SSDP sweep also
inventoried a Synology DS1522+ and a Sky gateway — the raw material for
future LLDP/mDNS cross-matching.

Fixes found by running it: added the confirmed MikroTik OUI 78:9A:18 (+
other RouterBOARD ranges) and an elvis-operator bug that printed "no UPnP
response" alongside valid UPnP data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:42:06 +02:00
mrambossekandClaude Opus 5 38f8036252 app: link.ra_source router identification + brand icons + DEV build variant
link.ra_source answers "who advertises IPv6 here, and which box is it":
RA source per network, MAC recovered from the modified-EUI-64 link-local
(privacy addresses reported as such, not guessed), vendor via a curated
OUI table, UPnP/SSDP M-SEARCH for the gateway's server banner + device
description (manufacturer/model/friendly name), and reverse DNS. All SSDP
responders are recorded so a rogue RA sender that isn't the gateway can
still be matched; the MAC accompanies every identity source as the hook
for future LLDP/mDNS cross-matching. UI gains a "Router / IPv6 advertiser"
panel.

Icons: branding adaptive icon converted to vector drawables (+ PNG
mipmaps, monochrome layer). The debug build is now a separate app —
applicationIdSuffix .dev, label "Echolot DEV", DEV-badged icon — so it
installs alongside a production build and can't be confused with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:35:54 +02:00
mrambossekandClaude Opus 5 dc094d1631 app: autorun mode (unattended run + upload + auto-exit); IPv6 severity rework
IPv6: absence is no longer a defect. If the network never provisioned v6
(no global address, no ::/0 route) the finding is ipv6.not_offered at INFO
(green) — most networks are still IPv4-only. If v6 IS advertised but
doesn't work, it's ipv6.broken at MEDIUM (yellow), because half-working v6
stalls connections. Verified on-device: our LAN advertises a v6 default
route with no path, and now reports ipv6.broken.

Autorun: `am start ... --ez autorun true` runs the suite immediately,
POSTs the report to the collection endpoint, shows the result for 3s and
finishes the activity (stays open if the upload failed). receiver.py gains
POST /report + GET /reports + GET /report/<name>. Verified end to end: one
adb command, report retrieved over HTTP, app closed itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:27:04 +02:00
mrambossekandClaude Opus 5 483de5ca54 app: nat.stun_5780 — NAT mapping/filtering discovery, verified vs live server
Hand-rolled RFC 5389/5780 STUN client (stdlib only) that exercises the
server's stun-5780 capability: one socket, three binding requests
(primary, OTHER-ADDRESS alternate IP, CHANGE-REQUEST port) — the
comparison classifies NAT mapping and filtering behavior.

Verified on the OnePlus: local 10.13.102.124 -> mapped
178.191.120.247:53259 (behind_nat true), alternate address answered from
the server's second IP, mapping endpoint-independent, filtering
address/port-dependent. Finding nat.symmetric (medium) for the
P2P-hostile case.

Two real bugs found by running it: port preservation was misread as "no
NAT" (compare addresses, not ports), and an unbound socket reports the
wildcard local address (resolve via a throwaway connected socket).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:15:29 +02:00
mrambossekandClaude Opus 5 dd9ecf5032 app: Shizuku shell tier verified on-device — 7/7 via UserService
Ran on the OnePlus with Shizuku started: tiers.shizuku=true, test ok,
commands_ok 7/7, exec_path=UserService (dual-path executor picked the
right path for this device). Evidence includes the live neighbor table,
per-table v6 routes, a real [NEIGH] netlink event, IpClient DHCP logs with
APF caps, and the wifi dump — as shell(2000).

Both v1 privilege tiers (app + shizuku) now verified end to end in the
production app on hardware. Report archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:06:24 +02:00
mrambossekandClaude Opus 5 59ba1c16bc app: dns.canary probe — client half of the canary measurement, verified live
Resolves the server's canary zone through the platform resolver and
compares against the spec-frozen ground truth (probe-protocol §6.1):
reference records detect answers rewritten in flight, and a per-run nonce
name (uncacheable) proves the query reached the authoritative server.
Findings: dns.answer_rewritten (high), dns.authoritative_unreachable
(medium).

Verified on the OnePlus against the deployed fmr zone: 4/4 reference
records matched exactly, nonce name answered 192.0.2.21 with
reached_authoritative=true. First full client<->server measurement loop
on real hardware; report archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:02:43 +02:00
mrambossekandClaude Opus 5 b751ca771e app: net.captive_portal verified on-device; archive first app run report
OnePlus 15 run: Android's generate_204 logic reproduced correctly —
default+wifi 204/204 -> validated, cellular -1/-1 -> no_internet (a
per-network asymmetry the OS itself hides). Shizuku tier degraded
correctly to UNSUPPORTED with tiers.shizuku=false since Shizuku isn't
running there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:17:35 +02:00
mrambossekandClaude Opus 5 4c51bd2aad beacon: stop causing "wireless debugging connected" notification spam
Root cause of the spam the user kept seeing: the service resolved adbd's
own mDNS advertisement repeatedly (every discovery callback, plus a 20s
heartbeat). Resolving that service makes adbd re-arm the connection, and
Android posts a "wireless debugging connected" notification each time —
so the beacon itself was the noise source, independent of the PC-side
connector loops.

Now: resolve each discovered service instance exactly ONCE (guard set,
cleared on onServiceLost so a genuine rotation re-resolves once), and the
heartbeat only re-POSTs the cached port (60s, no mDNS traffic).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:12:14 +02:00
mrambossekandClaude Opus 5 6e269d424b app: net.captive_portal probe — reproduce Android's internet/portal checks
Mirrors NetworkMonitor: per active network, fetch the AOSP default
generate_204 endpoints and check for HTTP 204 No Content.
- HTTPS https://www.google.com/generate_204 == 204 -> validated internet
- HTTP http://connectivitycheck.gstatic.com/generate_204: 204 -> clean;
  an unfollowed 3xx or a 200-with-body -> captive portal (Location captured)
- both fail -> no_internet
Per-network verdicts (bound via Network.openConnection), redirects not
followed (the 3xx IS the evidence). Findings: captive_portal (medium) and
no_internet (high). New test type net.captive_portal (net family ->
connectivity category). App gains usesCleartextTraffic (a network
diagnostic that intentionally probes plain HTTP).

Builds; measurement verdict tests still green. On-device verification
deferred with the rest (flaky test devices).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:01:22 +02:00
mrambossekandClaude Opus 5 aaed22dd3f app: core-shizuku — dual-path shell-tier executor + probe, wired into the app
Ports the prober's validated Shizuku tier: AIDL UserService, the build-4
dual-path ShizukuRunner (UserService bind where it works, legacy
newProcess reflection fallback where it doesn't — exec_path records
which), and ShizukuProbe running the shell command battery, emitting a
shizuku-tier link.ip_monitor Test with per-device dumps as evidence.
Self-degrades to UNSUPPORTED without Shizuku.

Wired into RunViewModel (sets tiers.shizuku); app APK assembles. On-device
verification deferred — no device reachable at build time (flaky LAN
dropped the tablet, phone debugging off). Expect UserService on OnePlus,
newProcess on Lenovo per the prober.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:37:48 +02:00
mrambossekandClaude Opus 5 7b9b312dcd beacon: own-IP filter (shared-LAN fix) + wireless-debugging-off warning
Live multi-device test exposed two things:
- On a shared LAN, NsdManager discovers EVERY device's
  _adb-tls-connect._tcp advertisement, so a phone reported the tablet's
  port for its own IP (crossed). Now only accept the resolved service
  whose host matches this device's own wlan0 IP.
- When Wireless debugging is turned off, adbd drops its mDNS
  advertisement (onServiceLost) — the app now says so plainly in the
  status line and the ongoing notification ("Wireless debugging appears
  OFF — re-enable it"), instead of a vague "waiting".

Verified with phone + tablet on the same LAN: correct per-device ports,
both auto-connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:13:13 +02:00
mrambossekandClaude Opus 5 38d4440412 tools: beacon fixes — cleartext, multi-device, validated-net POST, status URL
Debugging against a real restricted LAN surfaced three fixes:
- Android blocks app cleartext HTTP by default (targetSdk 36) — the port
  discovery + reachability were fine (phone curl reached fmr), only the
  app POST was denied. Added usesCleartextTraffic for this dev tool.
- Multi-device: report + receiver are keyed by device (Build.MODEL) so a
  phone and tablet don't clobber each other; connector connects each.
- POST over a VALIDATED internet network (prefer cellular) since the
  wireless-debug wifi is often a restricted LAN.
- Status/notification now show the target beacon URL + which network, per
  the request to surface what it's connecting to.
Verified live: beacon tracks the (frequently rotating) port via mDNS and
self-reports the current endpoint within seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:07:35 +02:00
mrambossekandClaude Opus 5 52748ac853 tools: wireless-adb beacon — self-healing bridge across drops + port rotation
The phone's wireless-debug port rotates and the bridge drops; this makes
adb reconnect automatically. Three pieces:
- adb-beacon (Android dev app): reads adbd's own mDNS advertisement
  (_adb-tls-connect._tcp) via NsdManager for the live connect port — no
  root, no Shizuku — plus the wlan0 IPv4, and POSTs {ip,port} to fmr every
  time it changes (continuous NSD discovery catches rotation in seconds).
  Foreground service (specialUse) so it survives backgrounding.
- tools/adb-beacon/receiver.py: ~30-line rendezvous on fmr:9099 (secret-
  gated POST stores the latest endpoint; GET returns it). Deployed as
  echolot-adb-beacon.service.
- PC connector polls the endpoint and keeps `adb connect` current.

Dev tooling, separate from the product. Bootstrap: sideload the beacon
APK once (no adb needed); thereafter adb self-heals for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:50:33 +02:00
mrambossekandClaude Opus 5 bc220f950e app: installable APK — core-probe (device-tier) + Compose UI
First assembling build of the production app. Android toolchain mirrors
the prober (AGP 9 built-in Kotlin; applying kotlin.android too
double-registers the kotlin extension — the one gotcha).

core-probe (Android lib): Probe→core-measurement Test abstraction;
NetworkInventory (LinkProperties→networks[]), LinkSnapshotProbe,
per-network IcmpProbe (ported from the prober's validated logic).

app (Compose): RunViewModel orchestrates probes into a MeasurementDocument
with a §7.3 summary + first-pass findings; UI renders traffic lights,
networks, tests, findings; JSON export. Rotation-safe (ViewModel).
App-tier only; server-facing (core-engine) + Shizuku are additive
follow-ups. Debug APK 9.5 MB, assembles clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:40:42 +02:00
mrambossekandClaude Opus 5 49c6197aff app: core-engine — run engine; full server-facing vertical proven vs fmr
Composes core-protocol probes into core-measurement documents. Injected
clock/UUID source keeps it pure and unit-testable. Runs a server ECHO
train and derives RTT distribution, loss, and NAT-rebinding detection
(from the server's observed source port) as train.udp_updown, then
findings + a §7.3 summary.

Verified end-to-end against fmr: 20-packet train, 0% loss, RTT
1.7/2.5/6.9ms, no rebinding → valid MeasurementDocument (2.3kB), overall
GREEN. The whole server-facing stack (protocol → engine → schema →
verdict) now produces the real product artifact against the live server,
no device required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:28:09 +02:00
mrambossekandClaude Opus 5 1f8860f7f8 app: core-measurement — the measurement-schema.md document model
Pure Kotlin/JVM, faithful to the schema contract: two-clock (wall RFC3339 +
*_mono_ns), units in field names, observation/interpretation split
(tests[] vs findings[]), columnar train evidence (nulls preserved per
index), the full v1 test-type registry, the anonymization logical types as
field notes, and a finding-requires-evidence invariant.

The one piece with real logic — §7.3 deterministic verdict derivation
(category = worst finding light; >50% failed/unsupported → inconclusive;
overall = worst category, inconclusive only if all are) — is implemented
in Verdicts and fully unit-tested. Document JSON round-trips (snake_case
wire names, null-in-columns), typed builders for train/traceroute/resolver
evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:24:20 +02:00
mrambossekandClaude Opus 5 3520eabd21 app: scaffold echolot-app + core-protocol — client spine verified live vs fmr
Multi-module Android app, built bottom-up from a verifiable core.
core-protocol is pure Kotlin/JVM (no Android SDK): SPKI-pinned control
plane (enroll/profile/session over HttpsURLConnection — API-1 compatible,
hostname verification off, trust is the pin), HKDF-SHA256 session keys,
ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
byte-compatible with the Go server.

Unit tests incl. the RFC 5869 HKDF vector (key derivation provably matches
the server). LiveServerTest + scripts/test-fmr.sh prove the client
end-to-end against the deployed fmr server: profile (8 caps), session,
ECHO rtt~11ms with the observation block returning our observed NAT port,
MTU 1400->1400, observations. Live test self-skips without ECHOLOT_LIVE_*.

Two client bugs caught live: java.net.http hostname verification (→
HttpsURLConnection, also the Android-minSdk-26 choice) and ECHO padding
needed for the observation to survive anti-amplification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:16:41 +02:00