Files
echolot/docs/probe-protocol.md
T
mrambossekandClaude Opus 5 e3840f54fe Initial commit: capability prober + design docs
Monorepo root for Echolot. Contains the no-root capability prober
(Kotlin/Compose, app.echo_lot.prober) and the four design docs that act
as the contract for the production app and the Go server.

LICENSE is deliberately absent — still undecided, see docs/build-status.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 08:54:28 +02:00

13 KiB

Echolot Probe Protocol — Spec v1 (draft 1)

Project name: Echolot (decided 2026-07-29; domain: echo-lot.app). Status: draft for review, 2026-07-29. Companion to measurement-schema.md (evidence fields reference observations defined here).

Two planes: a control plane (HTTPS + JSON, key-pinned) and a data plane (binary UDP probe protocol, TCP/TLS/HTTP echo endpoints, standard STUN, standard DNS on the canary zone).

1. Transport & trust

  • Control plane: HTTPS. The client validates the server certificate only against the SPKI pin from enrollment (pin-sha256, RFC 7469 computation). CA validation is not required; self-signed is first-class. Key rotation: profile may list next_pins[] ahead of rotation.
  • All control endpoints are under /v1/. Version negotiation is by capability list (§2.3), never by sniffing version numbers.
  • Data-plane authenticity: per-session HMAC key (§3), truncated HMAC on every UDP probe packet.

Default ports (all configurable): control HTTPS 8443; UDP probe 8442 (same number on v4 and v6); STUN 3478 (+ alternate address per RFC 5780 — requires a second IP); TCP echo 8441; canary DNS: standard 53 on the delegated zone.

2. Control plane

2.1 Enrollment

Bootstrap payload (QR / paste string):

echolot://enroll?v=1&u=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=<enrollment token>
POST /v1/enroll        Authorization: Bearer <enrollment-token>
→ 200 { "device_credential": "<random 256-bit, b64url>",
        "device_id": "uuid",
        "profile": { ... §2.2 ... } }

Enrollment tokens are single-use with expiry, created in the admin UI, scoped enroll. The device credential is a long-lived bearer secret, scoped run-tests; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI.

2.2 Profile

GET /v1/profile        Authorization: Bearer <device-credential>
{
  "profile_version": 7,
  "name": "homelab",
  "server_version": "0.4.0",
  "capabilities": ["udp-probe", "stun-5780", "canary-dns", "connect-back",
                    "delayed-echo", "big-send", "tls-echo", "throughput", "ntp"],
  "targets": [
    { "id": "vie-1", "location": "Vienna",
      "ip4": "203.0.113.10", "ip6": "2001:db8::10",
      "udp_port": 8442, "tcp_port": 8441, "stun_port": 3478,
      "stun_alt": { "ip4": "203.0.113.11", "port": 3478 } }
  ],
  "canary_zone": "c.probe.example.net",
  "recursive_resolver": { "ip4": "203.0.113.10", "dot": true, "doh_url": "https://..." },
  "pins": ["pin-sha256:..."], "next_pins": [],
  "limits": { "max_kbps": 50000, "max_session_s": 900 }
}

The app re-fetches the profile at the start of every run (falling back to the cached copy offline) and records profile_version in the measurement.

2.3 Capabilities (v1 registry)

udp-probe, stun-basic, stun-5780, canary-dns, recursive-dns, connect-back, delayed-echo, big-send, frag-send, tls-echo, http-echo, throughput, ntp. A server omits what it can't offer (e.g. stun-5780 without a second IP degrades to stun-basic). Clients must skip, and record as unsupported, any test whose capability is absent. Unknown capability strings are ignored.

2.4 Sessions

Every measurement run opens one session per target:

POST /v1/sessions      Authorization: Bearer <device-credential>
  { "target": "vie-1" }
→ 201 { "session_id": "<opaque, 8-byte hex prefix used on the wire>",
        "key_salt": "<b64, 16 bytes>",
        "epoch": "server wall-clock RFC3339 at session start",
        "expires_s": 900 }

Session key: HKDF-SHA256(ikm = device_credential, salt = key_salt, info = "echolot-v1/" + session_id) → 32 bytes. Both sides derive it; it never crosses the wire.

GET  /v1/sessions/{id}/observations     → §6
POST /v1/sessions/{id}/actions          → §5 (connect-back, delayed echo, big/frag send)
DELETE /v1/sessions/{id}

2.5 Rate limiting & abuse

Per-credential and per-source-IP token buckets on: session creation, actions, UDP packets, bytes. 429 on control plane; silent drop on data plane (probes must tolerate loss anyway). All reflected/generated traffic goes only to the session's observed source address (or, for connect-back, the source address of the session-creating request). Data-plane responses to unauthenticated packets are never larger than the request (§3.4).

3. UDP probe protocol

3.1 Packet header (fixed 32 bytes, network byte order)

offset  size  field
0       4     magic  "ELT1"
4       1     type
5       1     flags
6       2     payload_len
8       8     session_prefix  (first 8 bytes of session_id)
16      4     seq
20      8     t_ns    (sender timestamp, ns since session epoch, sender's clock)
28      4     hmac32  (first 4 bytes of HMAC-SHA256(session_key, header[0..28] || payload))

Padding payload is arbitrary bytes counted by payload_len; total datagram size is what MTU/train tests vary. hmac32 is an anti-abuse gate, not cryptographic integrity for hostile networks — 4 bytes is enough to make blind reflection/replay impractical at the allowed rates; on-path attackers are out of scope for the data plane (tampering is what the tests detect). Server drops packets with unknown session prefix, bad HMAC, expired session, or seq replay outside a 1024-wide window.

3.2 Types

type dir purpose
0x01 ECHO_REQ / 0x02 ECHO_RESP c→s / s→c RTT, loss, reordering; RESP carries observation block (§3.3)
0x03 TRAIN_DATA c→s upstream train, no per-packet response
0x04 TRAIN_REPORT_REQ / 0x05 TRAIN_REPORT c→s / s→c server's received-view of an upstream train (columnar, may span multiple RESP datagrams)
0x06 DOWNTRAIN_DATA s→c downstream train (scheduled via §5 action)
0x07 TIMESYNC_REQ / 0x08 TIMESYNC_RESP c→s / s→c 4-timestamp exchange: client t1 in REQ; RESP carries t2 (rx) and t3 (tx); t4 on receipt. Feeds time.server_offset
0x09 MTU_PROBE / 0x0A MTU_ACK c→s / s→c client sends DF-flagged sizes; ACK reports size received (ACK is small — never amplifies)
0x0B DELAYED_ECHO s→c single packet sent T seconds after the action request (NAT mapping lifetime)

3.3 Observation block (in ECHO_RESP, appended per-packet)

Server reports what it saw on the corresponding request: t_rx_ns, t_tx_ns (server clock, session epoch), observed source IP + port (detects NAT rebinding mid-flow), received TTL/hop-limit, DSCP, ECN bits, received size. This is the raw material for train.udp_updown, sec.dscp_ecn_survival, and TTL-based path-length evidence in the measurement schema.

3.4 Anti-amplification rule (normative)

For any datagram whose HMAC does not verify: no response ever. For verified packets: ECHO_RESP/MTU_ACK/TRAIN_REPORT responses are ≤ request size unless the session is in an asymmetric grant created via an authenticated control-plane action (§5), which sets an explicit byte budget and rate. DOWNTRAIN and big/frag sends exist only under such grants.

4. TCP, TLS and HTTP endpoints

  • TCP echo (8441): after connect, server sends one JSON line: observed source IP/port, negotiated MSS (from TCP_INFO), timestamps/window-scale options seen — this is the mtu.mss_observed evidence. Then byte-echo until FIN. TLS variant on the same port via ALPN elt-echo (capability tls-echo): server additionally returns the ClientHello it received, raw + JA4, before echoing — the sec.clienthello_echo evidence.
  • HTTP echo: POST /v1/echo on the control listener: returns exact received request bytes (headers + body) base64-wrapped in JSON, plus observed TLS parameters. Detects header injection/stripping/proxying (sec.http_echo). A plain-HTTP variant on a configurable port (default off) tests plaintext-path tampering.
  • TLS reference: GET /v1/tls-reference?host=<name> returns the full cert chain the server itself serves, DER+base64, so the app can compare an out-of-band copy against what a direct handshake yielded (sec.tls_reference).
  • STUN: unmodified RFC 5389/5780 on 3478; second address enables full behavior discovery. No custom framing — interop with existing tooling is a feature.

5. Actions (authenticated asymmetric operations)

POST /v1/sessions/{id}/actions with one of:

{ "action": "downtrain", "count": 1000, "size_bytes": 64, "interval_us": 20000, "dscp": 46 }
{ "action": "big_send",  "sizes_bytes": [1400, 1472, 1500, 1600, 2000], "df": true }
{ "action": "frag_send", "size_bytes": 2000, "family": "4 | 6" }
{ "action": "delayed_echo", "delay_s": 30 }
{ "action": "connect_back", "protocol": "tcp | udp", "port": 40123 }
{ "action": "throughput", "direction": "up | down", "streams": 4, "duration_s": 10 }

Rules: destination is always the session's observed source address; every action is bounded by limits from the profile; the response includes an action_id echoed in resulting data-plane packets (in payload) so evidence can be correlated. delayed_echo is the NAT-mapping-lifetime primitive: client binary-searches delay_s over repeated actions on fresh sockets.

6. Observations API

GET /v1/sessions/{id}/observations

Returns everything the server witnessed for this session, merged by the app into measurement evidence:

{
  "udp": { "packets_seen": ..., "trains": [ { columnar per-seq view } ] },
  "tcp": [ { "connected_at", "src", "mss", "options": [...] } ],
  "http": [ { "raw_request_b64", "tls": {...}, "ja4": "..." } ],
  "dns_canary": [
    { "qname": "x7f3a.sess1.c.probe.example.net", "at": "...",
      "resolver_ip": "198.51.100.7", "resolver_asn": 64500,
      "transport": "udp", "edns": { "present": true, "bufsize": 1232, "flags": ["do"] },
      "case_preserved": true, "qname_minimized": false }
  ],
  "connect_back": [ { "action_id", "result": "connected | refused | timeout", "rtt_ms" } ]
}

Canary DNS: the app generates <nonce>.<session-prefix>.<canary_zone> names and resolves them through the resolver under test (system/provider resolver, user-configured override resolvers, or the server's reference recursive); the authoritative server records who actually asked and how. Query-log retention is a server-config value surfaced in the admin UI (privacy default: 24 h). The ecs field in canary observations records any EDNS Client Subnet option the resolver forwarded.

6.1 Reference records (normative, capability canary-dns)

The server MUST serve these fixed records under the canary zone; TTLs and RDATA are defined by this spec (not configurable), so clients have ground truth without a side channel. Used by dns.ttl_integrity and dns.answer_integrity (measurement schema §6.4).

name type TTL RDATA
ttl-5.<zone> A / AAAA / TXT 5 192.0.2.5 / 2001:db8::5 / "echolot-ref ttl=5"
ttl-60.<zone> A / AAAA / TXT 60 192.0.2.60 / 2001:db8::60 / "echolot-ref ttl=60"
ttl-3600.<zone> A / AAAA / TXT 3600 192.0.2.36 / 2001:db8::3600 / "echolot-ref ttl=3600"
ttl-86400.<zone> A / AAAA / TXT 86400 192.0.2.86 / 2001:db8::8640 / "echolot-ref ttl=86400"
many-rr.<zone> A 300 exactly 8 A records in defined order (order/stripping check)
big-txt.<zone> TXT 300 ~1800 bytes (EDNS bufsize / TCP-fallback check)

Note: exact RDATA constants to be frozen in the implementation's dns_reference.go and mirrored in the app; the table above fixes the names and TTLs; RDATA must be deterministic, documentation-range addresses. Cache-miss variants: <nonce>.miss.<zone> wildcard answers with TTL 3600 and RDATA encoding the nonce (per-query ground truth that can never be pre-cached).

7. Server admin UI (scope note)

Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above.

8. Cross-references to the measurement schema

  • Observation-block fields (§3.3) appear as *_seen_by_server columns in train evidence (schema §6.2).
  • TIMESYNC (§3.2) produces the time.server_offset test; without it, cross-clock fields must not be compared (schema §6.2 note).
  • Capability strings (§2.3) are copied verbatim into server_sessions[].capabilities (schema §5); tests skipped for missing capability get status: "unsupported".
  • session_id maps to server_sessions[].session_id; action_ids appear in test params.

9. Open items

  1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a flags bit.
  2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with perf.* test design.
  3. Server mirror/self-test protocol between two echolot servers (nice for operators; postpone).
  4. IPv6 flow-label control for ECMP-variance tracing — needs IPV6_FLOWINFO sockopt verification on Android first (capability-prober item).