docs(protocol): add sanitized server compatibility research
This commit is contained in:
20 files changed
+4213
-175
No files matched your search
@@ -10,23 +10,22 @@
|
||||
|
||||
## Environment Check
|
||||
|
||||
```
|
||||
$ command -v irtt
|
||||
/Users/lochnair/.local/bin/irtt
|
||||
Tooling used for Part I. Installation paths are omitted deliberately; only the
|
||||
versions matter for reproducing these results.
|
||||
|
||||
```
|
||||
$ irtt version
|
||||
irtt version: 0.9.1
|
||||
protocol version: 1
|
||||
json format version: 1
|
||||
go version: go1.26.2 on darwin/arm64
|
||||
|
||||
$ command -v tshark
|
||||
/opt/homebrew/bin/tshark
|
||||
|
||||
$ tshark --version
|
||||
TShark (Wireshark) 4.6.4 (Git commit f7c4a74874d9).
|
||||
TShark (Wireshark) 4.6.4
|
||||
```
|
||||
|
||||
Reference server and client: the upstream **0.9.1 release**, unmodified.
|
||||
Capture platform: macOS Darwin 25.3.0 arm64, loopback interface.
|
||||
|
||||
---
|
||||
|
||||
## Open Question Resolution
|
||||
@@ -126,6 +125,11 @@ Timestamp field count depends on StampAt and Clock:
|
||||
This is a **normative requirement** — implementations must compute the
|
||||
correct minimum to avoid malformed packets.
|
||||
|
||||
**Caveat added 2026-08-11:** the two single-clock midpoint rows describe the
|
||||
*negotiated* layout, which is what a compatible implementation emits. Upstream
|
||||
0.9.1 emits 2 fields (16 bytes) for midpoint with any clock; see Findings S-7 and
|
||||
S-18.
|
||||
|
||||
---
|
||||
|
||||
### 19.4 Varint Encoding — RESOLVED
|
||||
@@ -154,7 +158,8 @@ verified against known values.
|
||||
|
||||
All decoded correctly using standard protobuf-style zigzag + LEB128.
|
||||
|
||||
**Verified independently with Python decoder.**
|
||||
**Cross-checked with a separately written Python decoder**, implemented from the
|
||||
wire-format description alone.
|
||||
|
||||
**Conclusion:** The varint encoding is standard protobuf-compatible. This
|
||||
is a **normative requirement** for interoperability.
|
||||
@@ -166,8 +171,8 @@ is a **normative requirement** for interoperability.
|
||||
**Question:** Is the HMAC computed over the entire packet (including payload)
|
||||
with the HMAC field zeroed?
|
||||
|
||||
**Test setup:** Captured HMAC session, extracted packet bytes, independently
|
||||
computed HMAC-MD5 with Python.
|
||||
**Test setup:** Captured HMAC session, extracted packet bytes, and recomputed
|
||||
HMAC-MD5 with a separately written Python script.
|
||||
|
||||
**Commands:**
|
||||
```
|
||||
@@ -228,6 +233,11 @@ or a bug.
|
||||
can be used as an invalidity sentinel. This is a **compatibility
|
||||
recommendation** — implementations SHOULD treat window=0 as invalid.
|
||||
|
||||
**Superseded in part by Finding S-2 (Part II).** Upstream 0.9.1 was later
|
||||
confirmed never to emit 0 at all when the window is negotiated. The value that
|
||||
actually needs care is `0x1`, which is a *valid* window carrying no history and
|
||||
must not be read as "nothing earlier was received".
|
||||
|
||||
---
|
||||
|
||||
### 19.8 HMAC Mismatch Behavior — RESOLVED
|
||||
@@ -308,6 +318,11 @@ ServerFill string (32 bytes max), the parameter buffer stays well under
|
||||
Reclassified as **compatibility recommendation** — implementations should
|
||||
allocate at least 128 bytes for the parameter buffer.
|
||||
|
||||
**Superseded by Part II.** Upstream servers were later shown to accept an open
|
||||
request carrying over 200 bytes of parameters. There is no protocol limit on a
|
||||
*received* payload; 128 bytes is a local allocation choice only, and a receiver
|
||||
should tolerate more rather than reject.
|
||||
|
||||
---
|
||||
|
||||
### 19.11 Field Ordering Verification — RESOLVED
|
||||
@@ -344,22 +359,26 @@ allocate at least 128 bytes for the parameter buffer.
|
||||
|
||||
---
|
||||
|
||||
### 19.3 Server Close During Test — NOT DIRECTLY TESTED
|
||||
### 19.3 Server Close During Test — NOT DIRECTLY TESTED (superseded)
|
||||
|
||||
**Limitation:** Triggering a server-initiated close mid-test requires
|
||||
specific server conditions that are difficult to set up reliably in a
|
||||
**Limitation at the time:** Triggering a server-initiated close mid-test requires
|
||||
specific server conditions that were difficult to set up reliably in a
|
||||
black-box test. The spec's description (server MAY set Close flag on echo
|
||||
reply) has not been verified externally. Reclassified as **open /
|
||||
verification required** — the spec now recommends defensive handling
|
||||
without asserting that this behavior occurs.
|
||||
reply) had not been verified externally, and was reclassified as **open /
|
||||
verification required**.
|
||||
|
||||
**Resolved in Part II by Finding S-4.** The maximum-test-duration hard limit was
|
||||
found to trigger it reliably, and the behavior was captured. This entry is
|
||||
retained only as a record of the earlier state.
|
||||
|
||||
---
|
||||
|
||||
### 19.6 Send Timestamp Capture Timing — NOT TESTABLE
|
||||
|
||||
**Limitation:** The exact timing of client-side timestamp capture relative
|
||||
to the send syscall is an implementation-internal detail that cannot be
|
||||
verified through black-box testing. Remains an implementation choice.
|
||||
**Limitation:** How closely a client's own timestamp capture sits to its send
|
||||
and receive operations is internal to that client and cannot be verified through
|
||||
black-box testing, in either direction. Remains an implementation choice, and
|
||||
nothing normative depends on it.
|
||||
|
||||
---
|
||||
|
||||
@@ -497,8 +516,8 @@ open request.
|
||||
### Finding E: Test Vector Size Correction
|
||||
|
||||
The test vectors in Section 18 are described as "target length 256 bytes
|
||||
(shown truncated)." Independent HMAC verification confirms that both test
|
||||
vectors (18.1 and 18.2) were computed over 92-byte packets (76-byte header
|
||||
(shown truncated)." Recomputing the HMAC from the captured bytes confirms that
|
||||
both vectors (18.1 and 18.2) were computed over 92-byte packets (76-byte header
|
||||
+ 16-byte payload), not 256-byte packets. The "256 bytes" description is
|
||||
incorrect.
|
||||
|
||||
@@ -560,6 +579,12 @@ irtt client -l 200 127.0.0.1 # request length 200
|
||||
|
||||
## Captures Created
|
||||
|
||||
All capture files referenced in this report were metadata-reviewed and their
|
||||
containers rewritten to strip capture comments, section and interface
|
||||
descriptions, host/user/machine identification, capture filter strings and
|
||||
filesystem paths. Packet bytes and timestamps were not altered — full frame
|
||||
hexdumps and per-frame epoch timestamps were verified identical before and after.
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `captures/full-session.pcapng` | Complete session: open, 3 echoes, close |
|
||||
@@ -608,3 +633,551 @@ See `test-vectors/` directory.
|
||||
defensive handling without asserting the behavior occurs
|
||||
6. Received window late-packet behavior: removed unverified claim about
|
||||
late packets producing window=0; retained verified bit-0 sentinel
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# Part II — Server-Side Verification
|
||||
|
||||
**Date:** 2026-08-11
|
||||
**Baseline:** the upstream **0.9.1 release** (protocol version 1)
|
||||
**OS:** macOS Darwin 25.5.0 arm64 (Apple Silicon)
|
||||
|
||||
**Method, and what each part of it establishes:**
|
||||
|
||||
- **Black-box observation.** A raw UDP harness drove a real upstream server
|
||||
directly, so that requests no conforming client would emit could be tested. It
|
||||
shares no code with any implementation. Everything it measured is stated here
|
||||
as observed wire behavior.
|
||||
- **Black-box observation at the application layer.** The real upstream 0.9.1
|
||||
*client* was driven against various servers, and its externally visible
|
||||
reaction — what it recorded, what it reported, how it exited — was observed.
|
||||
The client is an independent second implementation for this purpose.
|
||||
- **Contaminated-side consistency validation.** A server was also written on the
|
||||
contaminated side from the behavioral model and driven by the real upstream
|
||||
client. Because it was produced on this side of the boundary it is **not** an
|
||||
independent implementation, and no statement in this report rests on it. What
|
||||
it provides is a consistency check: it demonstrates that the model as written
|
||||
is sufficient to drive a real upstream client. The *client's* reaction to it is
|
||||
black-box observation; the server itself is not.
|
||||
|
||||
Findings feed `IRTT_SERVER_PROTOCOL_SPEC.md`. Input → output tables are in
|
||||
`test-vectors/SERVER_BEHAVIORAL_VECTORS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Part II Environment
|
||||
|
||||
```
|
||||
$ irtt version
|
||||
irtt version: 0.9.1
|
||||
protocol version: 1
|
||||
json format version: 1
|
||||
```
|
||||
|
||||
**Provenance of the second binary.** A second server binary was built from the
|
||||
upstream development tree as it stood **six upstream commits past the 0.9.1
|
||||
tag**, for the version-difference comparison in Finding S-13. Those six commits
|
||||
are post-release work and are **not** part of 0.9.1; every result attributed to
|
||||
that build is labelled as such and never folded into the 0.9.1 baseline.
|
||||
|
||||
*(An earlier revision of this report said "8 commits past the 0.9.1 tag". That
|
||||
count was wrong: it included two commits belonging to this clean-room project
|
||||
itself, which were present in the same working tree but are not upstream work.
|
||||
The upstream count is six.)*
|
||||
|
||||
---
|
||||
|
||||
## Finding S-1: Session identity is token + source endpoint
|
||||
|
||||
A post-open request is bound to a session by its token **and** by an exact match
|
||||
on the source address, source port, and address family.
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| Correct token, original endpoint | Served |
|
||||
| Correct token, different source port | **Dropped, no reply; session intact** |
|
||||
| Correct token, other address family's listener on the same port | Dropped |
|
||||
| Unknown token | Dropped |
|
||||
| Zero token | Dropped |
|
||||
| Token of a closed session | Dropped |
|
||||
| **Close** bearing a valid token from a foreign source port | **Dropped; session not closed** |
|
||||
|
||||
Endpoint binding protects the close path as well as the echo path. A capture is
|
||||
in `captures/server-session-identity.pcapng`.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-2: Received window resets to `0x1` on reordering and large gaps
|
||||
|
||||
The single most consequential server behavior found. A request arriving with a
|
||||
sequence number lower than the highest already seen, or 64 or more ahead of the
|
||||
previous one, causes the reported window to become `0x1` — history is discarded,
|
||||
not merely left unshifted, and it is never recovered.
|
||||
|
||||
```
|
||||
seq 0 -> count 1 window 0x0000000000000001
|
||||
seq 1 -> count 2 window 0x0000000000000003
|
||||
seq 5 -> count 3 window 0x0000000000000031
|
||||
seq 3 -> count 4 window 0x0000000000000001 <- gap fill discards history
|
||||
```
|
||||
|
||||
Duplicates leave the window unchanged and advance the count. 32-bit sequence
|
||||
wraparound is handled as ordinary modular arithmetic. Window value 0 was never
|
||||
observed. Full tables in `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1;
|
||||
capture in `captures/server-recv-window.pcapng`.
|
||||
|
||||
The three-case rule stated in `IRTT_SERVER_PROTOCOL_SPEC.md` Section 10.2 was
|
||||
cross-checked against a live server over 12 sessions of 40 requests each — 480
|
||||
replies — with sequence numbers drawn from a mix of in-order steps, duplicates,
|
||||
forward gaps of 2 to 80 and backward steps of 1 to 5, and starting sequence
|
||||
numbers of 0, 1, 7, 100 and 4294967294. **480 replies checked, 0 mismatches** in
|
||||
either window or count.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION **and** CLIENT SPEC
|
||||
CLARIFICATION (client spec Sections 12.8 and 19.15).
|
||||
|
||||
## Finding S-3: Received count semantics
|
||||
|
||||
The count advances by one for every request that reaches the reply stage,
|
||||
including duplicates and out-of-order arrivals, and never for requests dropped
|
||||
for bad magic, bad flags, bad authentication, unknown token, endpoint mismatch,
|
||||
oversize, or rate limiting. It is per session and includes the request being
|
||||
answered. Confirmed against a rate-limited server: 12 back-to-back requests
|
||||
produced 5 replies with counts 1–5, and three later requests continued 6–8.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-4: Server-initiated close exists and is triggered by the duration limit
|
||||
|
||||
Resolves client-spec open question 19.3. A server with a maximum test duration
|
||||
answers the first echo request past its hard deadline with a complete echo reply
|
||||
carrying flags `0x06` (Reply|Close), and removes the session at that moment.
|
||||
|
||||
| Configured maximum duration | Close flag observed at |
|
||||
|---|---|
|
||||
| 1 s | 3.01 s after the first echo |
|
||||
| 2 s | 4.06 s / 4.10 s after the first echo |
|
||||
|
||||
The deadline is the maximum duration plus a 2-second grace, measured from the
|
||||
session's first echo request — not from open. Capture:
|
||||
`captures/server-close.pcapng`, frame 36.
|
||||
|
||||
Driving an upstream 0.9.1 client against a server that sets the flag mid-test:
|
||||
the client discards that reply's measurement, terminates with a "server closed
|
||||
connection" condition, prints partial results, and exits successfully.
|
||||
|
||||
No other condition — idle expiry, shutdown, rate limiting, oversize, auth
|
||||
failure — was observed to set the flag.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION **and** CLIENT SPEC
|
||||
CLARIFICATION (client spec Sections 13.7 and 19.3).
|
||||
|
||||
## Finding S-5: Client-initiated close is never acknowledged, and a close reply is harmless
|
||||
|
||||
Upstream 0.9.1 sends **nothing** in response to a close request. Repeated closes
|
||||
and post-close echoes are dropped as unknown-token packets. Capture:
|
||||
`captures/server-close-lifecycle.pcapng` ends with three unanswered client
|
||||
packets.
|
||||
|
||||
A contaminated-side server that *does* reply to close — magic + flags(Reply|Close)
|
||||
+ [MAC] + token — was driven against a real 0.9.1 client and the run completed
|
||||
normally with correct results, with nothing in the client's output changing.
|
||||
Sending such a datagram is therefore harmless, but nothing was observed to make
|
||||
use of it: it has no demonstrated purpose in protocol version 1, and the server
|
||||
specification does not define it as required behavior.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-6: Protocol version is not enforced by the server
|
||||
|
||||
Open requests with `ProtocolVersion` of 0, 2, −1, or the tag omitted entirely,
|
||||
each produced a session-creating open reply (flags `0x03`, non-zero token) with
|
||||
`ProtocolVersion = 1` returned. The client spec's claim that "the server will
|
||||
also set the Close flag if there is a version mismatch" is not true of upstream
|
||||
0.9.1.
|
||||
|
||||
**Classification:** CLIENT SPEC CORRECTION (client spec Sections 6.2, 9.1,
|
||||
19.13).
|
||||
|
||||
## Finding S-7: Dual-field midpoint wire representation
|
||||
|
||||
With StampAt = midpoint and a single negotiated clock, the reply carries both
|
||||
midpoint fields — wall then monotonic — which is one 8-byte field more than the
|
||||
negotiated parameters imply, and makes the datagram longer at small negotiated
|
||||
lengths. Measured across every combination of received-stats and clock; confirmed
|
||||
at the application layer by an upstream client reporting 36-byte packets while
|
||||
receiving 44-byte datagrams and completing normally.
|
||||
|
||||
A contaminated-side server emitting only the negotiated midpoint field was driven
|
||||
by the 0.9.1 client for wall-only, monotonic-only and dual-clock configurations,
|
||||
and the client completed every run cleanly. This is consistency validation, not
|
||||
an independent observation; what it shows is that the *client* accepts the
|
||||
conforming form.
|
||||
|
||||
**Extended 2026-08-11 — see Finding S-18** for the negotiated-length dependence,
|
||||
the version comparison, and the correction to the "always +8" characterisation.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION **and** CLIENT SPEC
|
||||
CLARIFICATION (client spec Sections 8.5 and 19.14).
|
||||
|
||||
## Finding S-8: Interval restriction resolves the client's safety floor
|
||||
|
||||
A server restricts the negotiated interval to at most one quarter of its idle
|
||||
timeout (measured: 4 s → 1 s, 8 s → 2 s, 20 s → 5 s, 40 s → 10 s, 60 s → 15 s).
|
||||
This gives an ordinary, unmodified server that reduces a client's interval, which
|
||||
resolves client-spec open question 19.9.
|
||||
|
||||
| Server idle timeout | Returned interval | Client outcome |
|
||||
|---|---|---|
|
||||
| 2 s | 500 ms | Aborts — strict **and** loose |
|
||||
| 3 s | 750 ms | Aborts — strict **and** loose |
|
||||
| 4 s | 1 s (no reduction) | Proceeds |
|
||||
|
||||
Reduction to exactly 1 s is an ordinary restriction: rejected in strict mode,
|
||||
accepted in loose mode. The safety floor is **1 second**.
|
||||
|
||||
**Classification:** CLIENT SPEC CLARIFICATION (client spec Sections 6.2, 19.9).
|
||||
|
||||
## Finding S-9: Every rejection is a silent drop
|
||||
|
||||
No error, reset, or NACK exists anywhere in the protocol. Verified for: short
|
||||
datagrams, bad magic, reserved flag bits, the Reply flag on an inbound packet,
|
||||
every authentication failure mode, malformed parameters, invalid parameter enum
|
||||
values, unknown tokens, endpoint mismatch, oversize requests and rate limiting.
|
||||
Full table in `IRTT_SERVER_PROTOCOL_SPEC.md` Section 17.
|
||||
|
||||
Minimum accepted request sizes without authentication: open 4 bytes, close
|
||||
12 bytes, echo 16 bytes; add 16 bytes with authentication.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION, CONSISTENT with the client
|
||||
spec's HMAC-drop finding (19.8).
|
||||
|
||||
## Finding S-10: Authentication must be applied to replies, including echo replies
|
||||
|
||||
A contaminated-side server that authenticated its open reply correctly but
|
||||
omitted the HMAC flag from echo replies caused the 0.9.1 client to discard every
|
||||
echo reply and terminate abnormally with zero packets received. Fixing the flag
|
||||
made all configurations pass. The load-bearing observation here is the **client's**
|
||||
reaction, which is black-box; the server was merely the stimulus.
|
||||
|
||||
All authentication failures — missing flag, missing field, wrong key, flipped
|
||||
bit, zeroed MAC, truncated MAC, flag/MAC disagreement in either direction —
|
||||
produce a silent drop with no session state change. A session survived eight
|
||||
consecutive failing probes and served the next valid request normally.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-11: Idle expiry has a 5-second grace, and never-used sessions do not expire
|
||||
|
||||
With a 2-second idle timeout, the boundary was measured between 6.9 s and 7.1 s,
|
||||
i.e. timeout + 5 s. The first request past the deadline is still answered and
|
||||
the session is released while handling it; only the next request is dropped.
|
||||
|
||||
A session that has never carried an echo request does not expire at all: opened
|
||||
and left idle for 9 seconds under a 2-second timeout, it was served normally.
|
||||
Combined with the fact that every open — including a retransmitted one — creates
|
||||
a new session, this means open retransmissions accumulate permanent state.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-12: No session or per-peer limits by default
|
||||
|
||||
1000 sequential opens from a single source endpoint all succeeded with distinct
|
||||
tokens, and the first, middle and last were all still usable afterwards. Two
|
||||
opens from the same source port yield two independently usable sessions.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-13: Tested post-0.9.1 tree — CLI-only difference
|
||||
|
||||
The development tree built for this study is **six upstream commits** past the
|
||||
0.9.1 tag. Those commits are not part of the 0.9.1 release, and nothing in this
|
||||
finding describes 0.9.1. The only functional change among them is an opt-in
|
||||
server ECN option; the remainder are documentation, roadmap and dependency
|
||||
updates.
|
||||
|
||||
- The option's documentation states that it "disables UDP replies from server."
|
||||
**Observed behavior contradicts this**: with the option enabled, sessions
|
||||
completed normally over both IPv4 and IPv6 (4/4 packets, no loss).
|
||||
- Enabling it forces the source-IP-pinning policy.
|
||||
- ECN observation is a receive-side property of the IP header and does not
|
||||
change the IRTT payload in either direction.
|
||||
|
||||
A side-by-side run of both builds with an identical scripted request sequence
|
||||
(open with full statistics and dual timestamps on both clocks; echoes 0, 1, 4,
|
||||
3; close) produced identical structure: same open-reply layout, 60-byte replies,
|
||||
same flags, counts 1–4, windows `0x1`, `0x3`, `0x19`, `0x1`, and no close reply
|
||||
in either case.
|
||||
|
||||
**Classification:** VERSION DIFFERENCE — CLI / server-policy. Not wire-visible.
|
||||
|
||||
## Finding S-14: Reply length is capped by the interface MTU but the negotiated value is not
|
||||
|
||||
On a 1500-byte-MTU interface, negotiated lengths of 1501, 2000, 3000 and 8000
|
||||
each produced 1500-byte replies while the open reply returned the requested
|
||||
value unchanged. Clients treat a short reply as fatal, so such a session opens
|
||||
and then fails on the first echo.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-15: Robustness hazards reachable from ordinary traffic
|
||||
|
||||
Two conditions terminate the upstream server process. Both are recorded in
|
||||
`IRTT_SERVER_PROTOCOL_SPEC.md` Section 22 so that a clean implementation does
|
||||
not reproduce them.
|
||||
|
||||
1. **Oversized reply.** If the negotiated length exceeds the host's maximum
|
||||
outbound datagram size, the send fails with a non-transient error, which is
|
||||
treated as fatal: the listener stops and the process exits. Observed on a
|
||||
host with a 9216-byte maximum: a session negotiating 15000 bytes killed the
|
||||
server on its first echo reply. The default configuration places no upper
|
||||
bound on the negotiated length.
|
||||
2. **Zero-length fill pattern.** A server-fill descriptor of the form
|
||||
`pattern:` with an empty hex body terminates the server during open handling,
|
||||
when the fill allow-list permits pattern fills. The default allow-list does
|
||||
not, so a default-configured server is not exposed. An unparseable hex body
|
||||
such as `pattern:zz` is handled correctly by falling back to the server
|
||||
default.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION.
|
||||
|
||||
## Finding S-16: Server fill is a continuous stream
|
||||
|
||||
The default fill is the repeating pattern `69 72 74 74`, and its phase is not
|
||||
reset per packet or per session — it advances continuously as bytes are consumed
|
||||
and carries over between sessions on the same listener.
|
||||
|
||||
```
|
||||
seq 0 payload: 72 74 74 69 72 74 74
|
||||
seq 1 payload: 69 72 74 74 69 72 74
|
||||
seq 2 payload: 74 69 72 74 74 69 72
|
||||
seq 3 payload: 74 74 69 72 74 74 69
|
||||
```
|
||||
|
||||
Under a no-fill policy the payload contains unspecified residual bytes; one
|
||||
observed reply's trailing bytes were a fragment of a previous packet's parameter
|
||||
payload.
|
||||
|
||||
**Classification:** SERVER-ONLY NEW INFORMATION; also noted as a client-side
|
||||
caution in client spec Section 8.5.
|
||||
|
||||
## Finding S-17: Contaminated-side consistency validation of the model
|
||||
|
||||
A server built from the behavioral model in `IRTT_SERVER_PROTOCOL_SPEC.md` — with
|
||||
no upstream code — was driven by the real 0.9.1 client across: default
|
||||
parameters; no statistics and no timestamps; midpoint with wall, monotonic and
|
||||
both clocks; send-only with wall; receive-only with monotonic; a 200-byte packet
|
||||
length; no-test mode; HMAC with a matching key; HMAC with a mismatched key; and
|
||||
IPv6. Every configuration produced a clean, lossless run with correct results,
|
||||
and the mismatched-key case correctly timed out.
|
||||
|
||||
**This is contaminated-side consistency validation, not independent
|
||||
verification.** The server was written on this side of the clean-room boundary,
|
||||
so it cannot corroborate anything about upstream. What it does show is that the
|
||||
specification as written is sufficient to build a server the real upstream client
|
||||
accepts — the client's acceptance being the black-box part. No statement anywhere
|
||||
in the outgoing documents rests on this exercise.
|
||||
|
||||
**Classification:** Consistency validation, not a finding.
|
||||
|
||||
---
|
||||
|
||||
## Part II Captures Created
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `captures/server-recv-window.pcapng` | Sequential, gap, duplicate, reorder and far-gap sequence numbers on one session |
|
||||
| `captures/server-close-lifecycle.pcapng` | Client close, repeated close, post-close echo — none answered |
|
||||
| `captures/server-close.pcapng` | Server-initiated close via the duration limit; frame 36 carries flags `0x06` |
|
||||
| `captures/server-session-identity.pcapng` | Foreign-source-port echo and close, unknown token, original endpoint still served |
|
||||
|
||||
## Part II Vectors Created
|
||||
|
||||
`test-vectors/SERVER_BEHAVIORAL_VECTORS.md`.
|
||||
|
||||
## Part II Client Spec Changes Applied
|
||||
|
||||
| Section | Change | Class |
|
||||
|---------|--------|-------|
|
||||
| 2 | Pointer to the companion server specification | Editorial |
|
||||
| 6.2, 9.1, 19.13 | Server does not enforce the protocol version | **CORRECTION** |
|
||||
| 6.2, 19.9 | Interval safety floor pinned to 1 second, enforced in loose mode too | **CLARIFICATION** |
|
||||
| 8.5, 19.14 | The one verified case in which a reply exceeds `compatible_reply_len`, scoped to the single extra length `upstream_0_9_1_reply_len`; `compatible_reply_len` rather than the negotiated Length is the normal minimum; payload content is not predictable | **CLARIFICATION** |
|
||||
| 12.8, 19.15 | A window of `0x1` carries no history; window 0 is never emitted | **CLARIFICATION** |
|
||||
| 13.7, 19.3 | Server-initiated close confirmed; observed client reaction | **CLARIFICATION** |
|
||||
| 19.10 | No protocol limit on received parameter payload size | **CLARIFICATION** |
|
||||
|
||||
## Part II Summary
|
||||
|
||||
- Resolved client-spec open questions: **19.3**, **19.9**, **19.10**.
|
||||
- New client-spec open questions resolved on discovery: **19.13**, **19.14**,
|
||||
**19.15**.
|
||||
- Still unresolved on the client side: **19.6** (not externally testable),
|
||||
**19.12** (hard to trigger).
|
||||
- Server-side unknowns are enumerated in `IRTT_SERVER_PROTOCOL_SPEC.md`
|
||||
Section 23.
|
||||
|
||||
---
|
||||
|
||||
# Part III — Midpoint Timestamp Follow-Up (2026-08-11)
|
||||
|
||||
A focused follow-up on Finding S-7. Three server builds were exercised with a raw
|
||||
UDP harness that performs the open negotiation, sends one echo request, and
|
||||
reports the exact reply datagram length and the bytes at the timestamp offsets:
|
||||
|
||||
| Build | Provenance |
|
||||
|-------|-----------|
|
||||
| **0.9.1** | the upstream release; the baseline for this study |
|
||||
| **0.9.0** | the preceding upstream release, for comparison |
|
||||
| **post-0.9.1 tree** | the upstream development tree six upstream commits past the 0.9.1 tag — post-release work, not part of any release |
|
||||
|
||||
All on macOS arm64, loopback.
|
||||
|
||||
**Provenance note.** The measurements below are black-box observations. Part III
|
||||
additionally records one **source-assisted historical conclusion** — a judgement
|
||||
about whether the measured 0.9.0/0.9.1 difference was intended — which is
|
||||
labelled where it appears and is **not** presented as independently verified.
|
||||
Nothing normative in the specifications depends on it, and the detailed
|
||||
contaminated-side evidence behind it was retained on the contaminated side and
|
||||
does not appear here.
|
||||
|
||||
## Finding S-18: Dual-field midpoint emission is length-dependent in effect
|
||||
|
||||
### The wire representation
|
||||
|
||||
For StampAt = midpoint, upstream 0.9.1 emits two 8-byte timestamp fields — wall
|
||||
first, monotonic second — for every negotiated Clock, occupying a fixed 16-byte
|
||||
region. Full StampAt × Clock matrix at negotiated length 0, received-stats =
|
||||
both, against 0.9.1:
|
||||
|
||||
| StampAt | Clock = wall | Clock = monotonic | Clock = both |
|
||||
|---------|--------------|-------------------|--------------|
|
||||
| none | 28 | 28 | 28 |
|
||||
| send | 36 | 36 | 44 |
|
||||
| receive | 36 | 36 | 44 |
|
||||
| both | 44 | 44 | 60 |
|
||||
| **midpoint** | **44** (expected 36) | **44** (expected 36) | 44 |
|
||||
|
||||
Every non-midpoint row matches the negotiated layout exactly. Only midpoint
|
||||
deviates, and only when a single clock is negotiated.
|
||||
|
||||
### The length dependence — correction to "always +8"
|
||||
|
||||
Reply length is the larger of the negotiated length and the field block; the
|
||||
extra field enlarges only the field block. Writing `normal_header` for the block
|
||||
the negotiated parameters imply and `upstream_header` for that block plus one
|
||||
8-byte timestamp field:
|
||||
|
||||
```
|
||||
compatible_reply_len = max(negotiated_length, normal_header)
|
||||
upstream_0_9_1_reply_len = max(negotiated_length, upstream_header)
|
||||
```
|
||||
|
||||
Negotiated-length sweep, 0.9.1, midpoint + wall + received-stats = both
|
||||
(`normal_header` 36, `upstream_header` 44):
|
||||
|
||||
| Negotiated length | 0 | 16 | 32 | 36 | 37 | 40 | 43 | 44 | 45 | 48 | 64 | 128 | 1024 | 4096 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Reply datagram | 44 | 44 | 44 | 44 | 44 | 44 | 44 | 44 | 45 | 48 | 64 | 128 | 1024 | 4096 |
|
||||
| Excess over a compatible server | +8 | +8 | +8 | +8 | +7 | +4 | +1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||
|
||||
Repeated for received-stats = none, count and window; the two minima shift to
|
||||
24/32, 28/36 and 32/40 and the pattern is identical. The dual-clock control case
|
||||
(midpoint + both clocks) showed zero excess at every length.
|
||||
|
||||
At negotiated length 4096 the midpoint region still contains both fields,
|
||||
followed by fill bytes — so the emission itself is unconditional; only its effect
|
||||
on the datagram length depends on the negotiated length.
|
||||
|
||||
**This corrects the earlier characterisation.** "Upstream replies are the
|
||||
negotiated length plus 8 bytes", or "the normal packet length plus 8", is true
|
||||
only while the negotiated length is at or below `normal_header`. Between the two
|
||||
minima the excess falls through +7…+1, and once the negotiated length reaches
|
||||
`upstream_header` the excess is **0** — the reply is exactly the negotiated
|
||||
length and the extra field displaces payload. Any surviving "+8 always" wording
|
||||
elsewhere is stale and wrong.
|
||||
|
||||
**And it is a per-negotiation figure.** Each column of the sweep is a distinct
|
||||
negotiation whose upstream reply has exactly one length,
|
||||
`upstream_0_9_1_reply_len`. The +8 / +7…+1 / 0 values are what the difference
|
||||
works out to across those different negotiations. No negotiation produced a range
|
||||
of lengths, and no intermediate length between `compatible_reply_len` and
|
||||
`upstream_0_9_1_reply_len` was ever observed. Wording that reads as "any reply up
|
||||
to 8 bytes over is acceptable" is equally stale and wrong.
|
||||
|
||||
### Reachable without requesting midpoint
|
||||
|
||||
Against a 0.9.1 server restricted to a single timestamp, a request for StampAt =
|
||||
both is answered with StampAt = midpoint (already recorded in the timestamp
|
||||
allowance table). Measured: requested `3/wall` → negotiated `4/wall`, reply 44
|
||||
where 36 was implied; requested `3/monotonic` → same; requested `3/both` →
|
||||
negotiated `4/both`, reply 44, no excess.
|
||||
|
||||
### Value consequences for a positional decoder
|
||||
|
||||
With Clock = monotonic the first 8 bytes of the midpoint region hold the wall
|
||||
value, so a decoder that expects one midpoint field reads a
|
||||
nanoseconds-since-epoch magnitude into its monotonic slot. Measured with the
|
||||
upstream 0.9.1 client itself (`--tstamp=midpoint --clock=monotonic`): reported
|
||||
server monotonic timestamps of 1786437872024631833 and 1786437872225745750 ns —
|
||||
about 56.6 years, not process uptime — with the run completing normally and
|
||||
reporting 72 bytes sent against 88 bytes received for two packets.
|
||||
|
||||
**Limit of what this supports.** The misread is verified; a general correction is
|
||||
not. Once the negotiated length reaches `upstream_header` the two forms are the
|
||||
same size:
|
||||
|
||||
```
|
||||
conforming single-clock: [mono][payload...]
|
||||
upstream 0.9.1: [wall][mono][payload...]
|
||||
```
|
||||
|
||||
and nothing in the datagram tells them apart. A client that unconditionally read
|
||||
the second 8-byte region as monotonic would, against a conforming server, be
|
||||
reading ordinary payload. The specifications therefore permit a correction only
|
||||
where the dual-field form is otherwise identifiable, and record the equal-length
|
||||
case as ambiguous rather than inventing a heuristic for it.
|
||||
|
||||
### Version comparison — black-box measured
|
||||
|
||||
Same harness, same parameters, negotiated length 0, received-stats = both:
|
||||
|
||||
| Build | midpoint + wall | midpoint + monotonic | midpoint + both |
|
||||
|-------|-----------------|----------------------|-----------------|
|
||||
| 0.9.0 | 36 bytes, one field | 36 bytes, one field, process-relative value | 44 bytes, two fields |
|
||||
| 0.9.1 | 44 bytes, two fields | 44 bytes, two fields | 44 bytes, two fields |
|
||||
| tested post-0.9.1 tree | 44 bytes, two fields | 44 bytes, two fields | 44 bytes, two fields |
|
||||
|
||||
0.9.0 emits exactly the negotiated fields; 0.9.1 emits the dual-field form; the
|
||||
tested post-0.9.1 tree behaves like 0.9.1. The full matrix and the length sweep
|
||||
were re-run against the post-0.9.1 build and matched 0.9.1 in every cell.
|
||||
|
||||
This is a measurement. It says which bytes each build put on the wire, and
|
||||
nothing about why.
|
||||
|
||||
### Deliberate or defect — source-assisted historical conclusion
|
||||
|
||||
**Labelled contaminated-side judgement. Not an independent observation, and not
|
||||
load-bearing.** Contaminated-side investigation of upstream's release history
|
||||
indicates that the 0.9.0-to-0.9.1 difference measured above is an **unintended
|
||||
regression** rather than a designed protocol feature. Nothing in upstream's
|
||||
published release notes or documentation refers to the behavior, and none of the
|
||||
six post-0.9.1 upstream commits changes it.
|
||||
|
||||
Three things about this paragraph:
|
||||
|
||||
1. The measured 0.9.0-versus-0.9.1 difference is stated separately, above, and
|
||||
stands entirely on its own.
|
||||
2. That measurement is *consistent with* this judgement but does not establish
|
||||
it. Intent is not observable from the wire.
|
||||
3. The contaminated-side evidence supporting the judgement was retained on the
|
||||
contaminated side and deliberately did not cross the boundary. Only the
|
||||
conclusion appears here.
|
||||
|
||||
No requirement in either specification depends on this paragraph. A reader who
|
||||
disregards it loses nothing normative.
|
||||
|
||||
**Classification:** SERVER SPEC CORRECTION (server spec Sections 9.2, 11.3.1,
|
||||
21.1, 21.2, 23.4, 24) **and** CLIENT SPEC CORRECTION (client spec Sections 8.5,
|
||||
19.14).
|
||||
@@ -1,8 +1,23 @@
|
||||
# IRTT-Compatible Client Protocol Specification
|
||||
|
||||
**Version:** 1.1-verified
|
||||
**Date:** 2026-04-28
|
||||
**Verified against:** irtt 0.9.1, protocol version 1, macOS Darwin 25.3.0 arm64
|
||||
- **Version:** 1.3-verified
|
||||
- **Date:** 2026-08-11
|
||||
- **Verified against:** irtt 0.9.1 release, protocol version 1, macOS Darwin
|
||||
25.3.0 / 25.5.0 arm64
|
||||
|
||||
**Changes in 1.2:** corrections and clarifications arising from server-side
|
||||
verification. See Sections 19.3, 19.9, 19.10, 19.13, 19.14 and 19.15, and the
|
||||
companion `IRTT_SERVER_PROTOCOL_SPEC.md`.
|
||||
|
||||
**Changes in 1.3:** the reply-length tolerance of Sections 8.5 and 19.14 is
|
||||
narrowed from "accept any longer reply" to the one verified single-clock midpoint
|
||||
case, and stated as a single additional accepted length —
|
||||
`upstream_0_9_1_reply_len` exactly — rather than a `+1..+8` range; the normal
|
||||
minimum and expected reply size is stated as `compatible_reply_len` rather than
|
||||
the negotiated `Length` value alone; the monotonic-only decoding ambiguity is
|
||||
recorded rather than papered over with a heuristic; the DSCP wire encoding is
|
||||
separated from user-facing notation (Section 10.8); and the stale suggestion that
|
||||
servers reject a protocol-version mismatch is removed (Section 6.2).
|
||||
|
||||
---
|
||||
|
||||
@@ -25,11 +40,12 @@ as interoperability requirements, consistent with RFC 2119.
|
||||
## 2. Scope
|
||||
|
||||
- **Client-only implementation.** Server implementation is out of scope unless
|
||||
server behavior is needed to understand client interoperability.
|
||||
server behavior is needed to understand client interoperability. Server
|
||||
behavior is specified separately in `IRTT_SERVER_PROTOCOL_SPEC.md`, which
|
||||
shares this document's wire format.
|
||||
- **Compatible with existing IRTT servers** running protocol version 1.
|
||||
- This specification describes protocol and measurement behavior; CLI design
|
||||
is not prescribed.
|
||||
- Server implementation is out of scope.
|
||||
|
||||
---
|
||||
|
||||
@@ -187,8 +203,11 @@ A client session progresses through the following externally observable states:
|
||||
extracted from the reply payload. The client stores the connection token.
|
||||
Transitions to Active Test (or Completed if the no-test flag was set).
|
||||
- An open reply with the close flag set, **when the client did NOT set
|
||||
Close in its request**: the server rejected the session (e.g., protocol
|
||||
version mismatch). Transition to Failed.
|
||||
Close in its request**: the server rejected the session. Transition to
|
||||
Failed. Note that upstream 0.9.1 servers were never observed to produce
|
||||
such a rejection — in particular they do **not** reject a mismatched
|
||||
protocol version this way (see below and Section 19.13) — so a client MUST
|
||||
handle the shape without depending on any server actually emitting it.
|
||||
- An open reply with the close flag set, **when the client DID set Close
|
||||
in its request** (no-test mode): the server acknowledged the open+close.
|
||||
The connection token is zero. Transition to Completed.
|
||||
@@ -203,12 +222,21 @@ A client session progresses through the following externally observable states:
|
||||
The client MUST reject (as an error) the following server parameter changes:
|
||||
- Duration increased beyond what was requested.
|
||||
- Length increased beyond what was requested.
|
||||
- Interval reduced below a safety floor (see Section 19.9 for verification).
|
||||
- Interval reduced below a safety floor of **1 second**. This rejection
|
||||
applies in loose mode as well as strict mode.
|
||||
[**Verified 2026-08-11** — see Section 19.9.]
|
||||
|
||||
- **Protocol version check:** The client MUST verify that the server's
|
||||
protocol version matches exactly. Protocol version 1 is the current
|
||||
version. Mismatch MUST cause the session to fail.
|
||||
|
||||
The client cannot rely on the server to detect the mismatch. Upstream 0.9.1
|
||||
servers do **not** reject a mismatched protocol version: they rewrite it to 1,
|
||||
open a normal session, and return `ProtocolVersion = 1` with the Close flag
|
||||
clear. Version checking is therefore effectively client-side only.
|
||||
[**Verified 2026-08-11** — see Section 19.13 and
|
||||
`IRTT_SERVER_PROTOCOL_SPEC.md` Section 7.5.]
|
||||
|
||||
### 6.3 Active Test
|
||||
|
||||
- **Entry:** Open reply received and parameters accepted.
|
||||
@@ -276,7 +304,7 @@ parameters).
|
||||
| Echo Request (Test Packet) | Client → Server | Active test | Yes | Carry test payload with sequence number |
|
||||
| Echo Reply | Server → Client | Active test | Yes | Return test payload with timestamps and stats |
|
||||
| Close Request | Client → Server | Closing phase | Yes | Signal session end |
|
||||
| Close Reply | Server → Client | Closing phase | No | Acknowledge close (server MAY send this) |
|
||||
| Close Reply | Server → Client | Closing phase | No | Not part of normal operation. Upstream servers send nothing in response to a close; such a datagram is tolerated by clients but has no demonstrated purpose in protocol version 1 |
|
||||
|
||||
---
|
||||
|
||||
@@ -445,7 +473,103 @@ set (see Section 19.3 — not yet verified by black-box testing).
|
||||
- Received Window (8 bytes) — if negotiated, contains 64-bit received bitmap
|
||||
- Timestamp fields — according to negotiated stamp_at and clock settings
|
||||
|
||||
**Payload:** Server-filled according to negotiation.
|
||||
**Payload:** Server-filled according to negotiation. The client MUST NOT rely on
|
||||
the payload's content: server fill is applied as a continuous stream, so a reply
|
||||
payload does not necessarily begin at the start of the configured fill pattern.
|
||||
|
||||
**Reply length:** The normal, expected size of an echo reply is
|
||||
`compatible_reply_len` — the larger of the negotiated Length and the mandatory
|
||||
field block, defined below. That, and not the negotiated `Length` value on its
|
||||
own, is the minimum: the client MUST reject a reply *shorter* than
|
||||
`compatible_reply_len`. (The two differ whenever the negotiated Length is smaller
|
||||
than the field block, including the common case of a negotiated Length of 0.)
|
||||
There is exactly one verified case in which a reply is *longer*, and the required
|
||||
tolerance is scoped to it — see below. This is **not** a general rule that
|
||||
overlong replies are acceptable; outside that one case, a client MUST keep
|
||||
applying its ordinary strict validation to unexpectedly sized datagrams.
|
||||
|
||||
**The one case: dual-field midpoint from upstream 0.9.1.** When midpoint
|
||||
timestamps are negotiated, upstream 0.9.1 servers emit **two** midpoint timestamp
|
||||
fields on the wire — the midpoint wall field followed by the midpoint monotonic
|
||||
field, in that order — **even when the negotiated clock selects only one clock**.
|
||||
A single-clock midpoint reply therefore carries one 8-byte field more than the
|
||||
negotiated parameters imply. (This describes bytes on the wire only; it implies
|
||||
nothing about how any server represents a midpoint internally.)
|
||||
|
||||
The extra field enlarges the reply's *minimum* size; it does **not** add 8 bytes
|
||||
to every reply. Writing `normal_header` for the field block the negotiated
|
||||
parameters imply and `upstream_header` for that block plus one 8-byte timestamp
|
||||
field:
|
||||
|
||||
```
|
||||
normal_header = negotiated field block
|
||||
upstream_header = normal_header + one 8-byte timestamp field
|
||||
|
||||
compatible_reply_len = max(negotiated_length, normal_header)
|
||||
upstream_0_9_1_reply_len = max(negotiated_length, upstream_header)
|
||||
```
|
||||
|
||||
Three distinct quantities, not to be conflated: `negotiated_length` is the value
|
||||
of the negotiated Length parameter; `normal_header` is the negotiated mandatory
|
||||
field block; `compatible_reply_len` is the actual normal packet length implied by
|
||||
both.
|
||||
|
||||
So the excess is +8 while the negotiated length is at or below `normal_header`,
|
||||
shrinks through +7…+1 between the two minima, and is **0** once the negotiated
|
||||
length reaches `upstream_header` — there the extra field displaces 8 bytes of
|
||||
payload instead of lengthening the datagram. Those figures are values the
|
||||
difference takes **across different negotiations**. For any one set of negotiated
|
||||
Params both quantities are fixed numbers, and the upstream reply has exactly one
|
||||
length.
|
||||
|
||||
**Required tolerance.** When the negotiated parameters are StampAt = Midpoint
|
||||
with a single Clock, a conforming client MUST accept a reply whose length is
|
||||
**exactly** `upstream_0_9_1_reply_len`. It MUST NOT require the extra field to be
|
||||
present, and MUST NOT treat its presence as an error.
|
||||
|
||||
Where `upstream_0_9_1_reply_len > compatible_reply_len`, that form is identifiable
|
||||
by length alone and the difference between the two values is between 1 and 8
|
||||
bytes. This is one extra accepted length, **not** a `+1..+8` window: a client MUST
|
||||
NOT accept an arbitrary length in that span, and MUST reject any length strictly
|
||||
between `compatible_reply_len` and `upstream_0_9_1_reply_len` just as it rejects
|
||||
any other unexpected length.
|
||||
|
||||
**Accepted echo reply lengths, in full.** Compute both values once from the
|
||||
negotiated Params. A conforming client then accepts exactly these lengths:
|
||||
|
||||
| Received `packet_len` | Condition | Outcome |
|
||||
|---|---|---|
|
||||
| `== compatible_reply_len` | always | **accepted** — the normal exact form |
|
||||
| `== upstream_0_9_1_reply_len` | single-clock Midpoint **and** `upstream_0_9_1_reply_len > compatible_reply_len` | **accepted** — the identifiable upstream midpoint compatibility form |
|
||||
| any other value | — | **rejected** |
|
||||
|
||||
When `upstream_0_9_1_reply_len == compatible_reply_len` the two rows collapse: the
|
||||
packet is accepted at the normal exact length, the wire form is ambiguous, and no
|
||||
generic monotonic correction is applied (see below).
|
||||
|
||||
**Values read by a positional decoder.** The extra field is last, and field
|
||||
offsets are derived from the negotiated parameters:
|
||||
|
||||
- **Clock = wall:** the negotiated field comes first, so the value read is
|
||||
correct.
|
||||
- **Clock = monotonic:** the first 8-byte region holds the *wall*-domain value, so
|
||||
an ordinary positional decoder reads a nanoseconds-since-epoch magnitude into
|
||||
its monotonic slot instead of a process-relative one.
|
||||
|
||||
**Do not correct for that blindly.** Once the negotiated length reaches
|
||||
`upstream_header`, the upstream form and a conforming single-clock form produce
|
||||
datagrams of the same size, and nothing distinguishes them — the second 8-byte
|
||||
region is a timestamp under one form and ordinary payload under the other. A
|
||||
client MAY take its monotonic value from the second region **only when the
|
||||
dual-field form is otherwise identifiable** — that is, when
|
||||
`upstream_0_9_1_reply_len > compatible_reply_len` for the negotiated Params and
|
||||
the datagram length is exactly `upstream_0_9_1_reply_len`. In the equal-length
|
||||
regime (`upstream_0_9_1_reply_len == compatible_reply_len`) the value SHOULD
|
||||
be recorded as ambiguous rather than guessed. Either way the packet MUST still be
|
||||
accepted, and a client that applies no correction MUST NOT fail on the resulting
|
||||
implausible value. See the server specification, Section 11.3.1.
|
||||
[**Verified 2026-08-11**, extended **2026-08-11** with a negotiated-length sweep
|
||||
— see Section 19.14.]
|
||||
|
||||
### 8.5.1 Close Request Packet
|
||||
|
||||
@@ -505,8 +629,10 @@ default to zero on the receiving end).
|
||||
|
||||
Unknown parameter tags MUST be silently ignored by the receiver.
|
||||
|
||||
The maximum serialized parameter buffer size is believed to be 128 bytes
|
||||
(see Section 19.10 for verification).
|
||||
There is **no protocol limit** on the size of a received parameter payload; a
|
||||
receiver MUST tolerate a payload larger than any it would itself produce.
|
||||
Allocating at least 128 bytes for the parameter buffer is a safe local
|
||||
implementation choice, not a protocol rule (see Section 19.10).
|
||||
|
||||
**ReceivedStats enum values:**
|
||||
|
||||
@@ -548,7 +674,14 @@ A conforming client MUST set `ProtocolVersion` to 1 in its open request.
|
||||
|
||||
A conforming client MUST verify that the server's returned protocol version
|
||||
matches exactly. If there is a mismatch, the client MUST abort the session.
|
||||
The server will also set the Close flag if there is a version mismatch.
|
||||
|
||||
**Do not depend on the server to signal the mismatch.** Upstream 0.9.1 servers
|
||||
accept any value in the `ProtocolVersion` parameter — including 0, 2, a negative
|
||||
value, or the tag being absent entirely — rewrite it to 1, create a normal
|
||||
session, and return an open reply with the Close flag clear and a non-zero
|
||||
token. A server MAY reject an unknown version by replying with the Close flag
|
||||
set and a zero token, but existing servers do not.
|
||||
[**Verified 2026-08-11** — see Section 19.13.]
|
||||
|
||||
### 9.2 Unknown Fields and Extensions
|
||||
|
||||
@@ -628,10 +761,18 @@ The following configuration parameters affect protocol behavior:
|
||||
- **Effect:** Sets the TOS/Traffic Class byte in the IP header for both
|
||||
client and server packets during the active test phase.
|
||||
- **Default:** 0 (best effort).
|
||||
- **Valid values:** 0-255 (full TOS byte value). The DSCP field occupies
|
||||
the upper 6 bits; the lower 2 bits (ECN) are typically zero.
|
||||
Common values: 0 (best effort), 0xb8 (EF), 0xa0 (CS5), 0x20 (CS1).
|
||||
[**Verified 2026-04-28** — see BLACKBOX_VERIFICATION_REPORT.md Finding B.]
|
||||
- **Wire encoding (protocol fact):** the DSCP parameter carries the **raw IP TOS
|
||||
/ Traffic Class byte**, range **0–255** — not a 6-bit codepoint. The
|
||||
differentiated-services codepoint occupies the upper 6 bits of that byte and
|
||||
the lower 2 (ECN) are typically zero, so Expedited Forwarding is **0xb8
|
||||
(184)** on the wire. Other common values: 0 (best effort), 0xa0 (CS5), 0x20
|
||||
(CS1). [**Verified 2026-04-28** — see BLACKBOX_VERIFICATION_REPORT.md
|
||||
Finding B.]
|
||||
- **Out of scope — user-facing notation.** Whether an implementation's command
|
||||
line and configuration accept a 0–63 codepoint (shifting it left by two to
|
||||
produce the wire byte) or the raw 0–255 byte is a presentation decision for
|
||||
that project, to be made and implemented separately. It is not a protocol
|
||||
fact and this specification does not prescribe it.
|
||||
- **Protocol:** The server echoes packets with the negotiated DSCP value.
|
||||
The server MAY disallow DSCP and reset it to 0.
|
||||
- **Observed behavior:** DSCP/TOS is applied only to echo request and echo
|
||||
@@ -696,9 +837,14 @@ The following configuration parameters affect protocol behavior:
|
||||
- **Client receive timestamp:** SHOULD be captured as close as possible to
|
||||
the receive system call return, before any packet processing.
|
||||
- **Server timestamps:** Determined by the negotiated StampAt parameter:
|
||||
- Receive: captured when the server receives the packet.
|
||||
- Send: captured just before the server sends the reply.
|
||||
- Midpoint: the average of receive and send times.
|
||||
- Receive: the server's arrival instant for the request.
|
||||
- Send: the server's departure instant for the reply.
|
||||
- Midpoint: the average of the two.
|
||||
|
||||
A client can rely on the pair bracketing the server's handling of that request,
|
||||
and on receive never being later than send. It cannot observe, and MUST NOT
|
||||
assume, anything about where a given server takes those readings internally or
|
||||
how much of its own send and receive path falls outside them.
|
||||
|
||||
### 11.2 Clock Types
|
||||
|
||||
@@ -857,22 +1003,46 @@ When the 64-bit received window is present in a reply:
|
||||
- A set bit means the server received that packet; a clear bit means it
|
||||
did not.
|
||||
|
||||
The window may not be valid for all replies. Bit 0 (LSB) is always set
|
||||
for valid windows, since it represents the current packet which was
|
||||
received. [**Verified 2026-04-28** — see Section 19.7.] Implementations
|
||||
SHOULD treat a window value of 0 as potentially invalid and avoid using
|
||||
it for loss classification.
|
||||
Bit 0 (LSB) is always set in a window emitted by a server, since it represents
|
||||
the current packet which was received. [**Verified 2026-04-28** — see
|
||||
Section 19.7.] Implementations SHOULD treat a window value of 0 as potentially
|
||||
invalid and avoid using it for loss classification. Note that upstream 0.9.1
|
||||
servers never emit 0 when the window is negotiated at all.
|
||||
[**Verified 2026-08-11** — see Section 19.15.]
|
||||
|
||||
**A window of `0x1` carries no historical information.** Upstream 0.9.1 servers
|
||||
reset the window to `0x1` — not merely fail to update it — whenever a request
|
||||
arrives with a sequence number lower than the highest already seen, or with a
|
||||
gap of 64 or more from the previous request. `0x1` does **not** mean the previous
|
||||
63 packets were lost; it means no useful prior received-history is represented in
|
||||
the window after that transition. A single reordered packet discards everything
|
||||
the window knew about the previous 63 sequence numbers, and later packets rebuild
|
||||
it from scratch. A client that reads `0x1` literally will misclassify up to 63
|
||||
earlier packets as lost upstream after one reordering event.
|
||||
|
||||
A conforming client SHOULD treat a window value of `0x1` on any reply other than
|
||||
the first of the session as "no historical information available" rather than as
|
||||
"the server received nothing earlier."
|
||||
[**Verified 2026-08-11** — see Section 19.15 and
|
||||
`test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1.]
|
||||
|
||||
The client processes the received window to classify previous packets:
|
||||
- If a packet was not received by the client but the window says the server
|
||||
received it → lost downstream.
|
||||
- If a packet was not received by the client and the window says the server
|
||||
did not receive it → lost upstream.
|
||||
- If the window cannot confirm (e.g., more than 64 packets ago, or window
|
||||
was invalid) → generic loss.
|
||||
- If the window cannot confirm (e.g., more than 64 packets ago, or the window
|
||||
carries no history) → generic loss.
|
||||
|
||||
The window has a maximum lookback of 63 packets (bits 1-63).
|
||||
|
||||
**Scope note.** The paragraphs above state what the window field *represents* on
|
||||
the wire, which is what was verified. The downstream/upstream loss
|
||||
classification built on top of it is a client-side interpretation question that
|
||||
has not been audited to the same standard here, and it is scheduled for its own
|
||||
review on the implementation side. Treat the classification rules as a starting
|
||||
point rather than as verified protocol semantics.
|
||||
|
||||
### 12.9 Statistics
|
||||
|
||||
For RTT, send delay, receive delay, and IPDV, a conforming client SHOULD
|
||||
@@ -935,13 +1105,24 @@ no receive or send timestamp fields may also be present.
|
||||
|
||||
### 13.7 Close Flag in Echo Reply
|
||||
|
||||
If the server sets the Close flag in an echo reply, the client SHOULD
|
||||
process the reply normally (record measurements) and then close the
|
||||
connection. This is defensive handling for the possibility that the
|
||||
server can forcibly end a session mid-test.
|
||||
A server MAY forcibly end a session mid-test by setting the Close flag (0x04) on
|
||||
an otherwise complete echo reply. This is the only in-band mechanism for a
|
||||
server-initiated close; there is no standalone server close packet.
|
||||
|
||||
**Note:** This behavior has not been directly verified by black-box
|
||||
testing (see Section 19.3). Implementations SHOULD handle it defensively.
|
||||
A client MUST handle this case. On receiving an echo reply with the Close flag
|
||||
set, the client MUST stop the test, MUST NOT send further test packets for that
|
||||
session, and SHOULD report the partial results gathered so far as a successful
|
||||
(if truncated) run rather than as an error.
|
||||
|
||||
Whether the close-flagged reply's own measurement is recorded is an
|
||||
implementation choice. Upstream 0.9.1 clients **discard** it: the run is
|
||||
terminated on the "server closed" condition before the measurement is recorded.
|
||||
|
||||
The session token is invalid from that moment; a close request the client sends
|
||||
afterwards will be silently dropped by the server, which is normal.
|
||||
|
||||
[**Verified 2026-08-11** — see Section 19.3 and
|
||||
`IRTT_SERVER_PROTOCOL_SPEC.md` Section 15.2.]
|
||||
|
||||
---
|
||||
|
||||
@@ -962,7 +1143,8 @@ testing (see Section 19.3). Implementations SHOULD handle it defensively.
|
||||
| Send error during test | SHOULD abort, report partial results. |
|
||||
| Receive error during test | SHOULD abort, report partial results. |
|
||||
| Individual malformed packet during test | SHOULD abort. |
|
||||
| Short reply (shorter than negotiated length) | SHOULD abort. |
|
||||
| Short reply (shorter than `compatible_reply_len`, the normal compatible reply length — not merely shorter than the negotiated Length) | SHOULD abort. |
|
||||
| Reply at an unexpected length (any length other than `compatible_reply_len`, or the single-clock midpoint form at exactly `upstream_0_9_1_reply_len`) | SHOULD abort. See Section 8.5. |
|
||||
| Stamp-at mismatch in reply | SHOULD abort. |
|
||||
| Clock mismatch in reply | SHOULD abort. |
|
||||
| Unexpected sequence number | SHOULD abort. |
|
||||
@@ -1331,18 +1513,32 @@ combinations. See BLACKBOX_VERIFICATION_REPORT.md Section 19.2 for the
|
||||
complete table. The minimum is 16 bytes (no stats, no timestamps) and the
|
||||
maximum default is 76 bytes (both+both+both+HMAC).
|
||||
|
||||
### 19.3 Server Close During Test — OPEN / Verification Required
|
||||
### 19.3 Server Close During Test — ✅ RESOLVED
|
||||
|
||||
**Status:** Not tested. Hard to trigger reliably with black-box testing.
|
||||
**Status:** Verified 2026-08-11, irtt 0.9.1, macOS arm64.
|
||||
|
||||
**Question:** Can the server set the Close flag on an echo reply mid-test
|
||||
(e.g., due to exceeding a duration limit)? If so, how should the client
|
||||
respond?
|
||||
**Result:** Yes. A server that enforces a maximum test duration answers the
|
||||
first echo request arriving after its hard deadline (the configured maximum
|
||||
duration plus a 2-second grace, measured from the session's first echo request)
|
||||
with a complete, ordinary echo reply that additionally has the Close flag set,
|
||||
and removes the session at the same moment. Measured: a 1-second maximum
|
||||
produced a close-flagged reply 3.01 s after the first echo; a 2-second maximum
|
||||
produced one at 4.06 s.
|
||||
|
||||
**Compatibility recommendation:** The client SHOULD check for the Close
|
||||
flag in echo replies and, if set, process the reply normally (record
|
||||
measurements) and then terminate the session. This is defensive handling
|
||||
for a scenario that has not been directly observed in black-box testing.
|
||||
A conforming client never reaches this deadline, because it stops sending at the
|
||||
negotiated duration. The mechanism exists to bound sessions from clients that
|
||||
ignore the negotiation.
|
||||
|
||||
Observed upstream 0.9.1 client reaction: it discards the close-flagged reply's
|
||||
measurement, terminates the run with a "server closed connection" condition,
|
||||
prints the partial results, and exits with a success status.
|
||||
|
||||
No other server condition was observed to set the Close flag on an echo reply:
|
||||
idle expiry, shutdown, rate limiting, oversize requests and authentication
|
||||
failure were each exercised and none produced one.
|
||||
|
||||
See Section 13.7, `IRTT_SERVER_PROTOCOL_SPEC.md` Section 15.2, and
|
||||
`captures/server-close.pcapng` (frame 36 carries flags `0x06`).
|
||||
|
||||
### 19.4 Varint Encoding Compatibility — ✅ RESOLVED
|
||||
|
||||
@@ -1357,8 +1553,8 @@ verified. See BLACKBOX_VERIFICATION_REPORT.md Section 19.4.
|
||||
**Status:** Verified 2026-04-28, irtt 0.9.1, macOS arm64.
|
||||
|
||||
**Result:** HMAC-MD5 is computed over the entire packet buffer (including
|
||||
payload) with the 16-byte HMAC field zeroed. Independently verified with
|
||||
Python for all four packet types (open request, echo request, echo reply,
|
||||
payload) with the 16-byte HMAC field zeroed. Cross-checked with a separately
|
||||
written Python script for all four packet types (open request, echo request, echo reply,
|
||||
close request). The spec's test vectors (18.1 and 18.2) also verify
|
||||
correctly at 92-byte packet size.
|
||||
|
||||
@@ -1379,6 +1575,12 @@ window values: seq 0 → 0x01, seq 1 → 0x03, seq 2 → 0x07. A window
|
||||
value of 0 can be used as the invalid sentinel. See
|
||||
BLACKBOX_VERIFICATION_REPORT.md Section 19.7.
|
||||
|
||||
**Extended 2026-08-11.** Upstream 0.9.1 was subsequently confirmed never to emit
|
||||
0 when the window is negotiated at all, and the far more common `0x1` was found
|
||||
to be a *valid* window carrying no history — which is a distinct condition from
|
||||
the 0 sentinel and must not be treated the same way. See Section 19.15 and
|
||||
Section 12.8.
|
||||
|
||||
### 19.8 Open Reply Without HMAC When Client Expects HMAC — ✅ RESOLVED
|
||||
|
||||
**Status:** Verified 2026-04-28, irtt 0.9.1, macOS arm64.
|
||||
@@ -1388,24 +1590,43 @@ The client times out with `[OpenTimeout] no reply from server`. Same
|
||||
behavior for wrong key and no key. See BLACKBOX_VERIFICATION_REPORT.md
|
||||
Section 19.8.
|
||||
|
||||
### 19.9 Minimum Restricted Interval Safety Floor — PARTIALLY RESOLVED
|
||||
### 19.9 Minimum Restricted Interval Safety Floor — ✅ RESOLVED
|
||||
|
||||
**Status:** Partially tested 2026-04-28.
|
||||
**Status:** Verified 2026-08-11, irtt 0.9.1, macOS arm64.
|
||||
|
||||
**Result:** In strict mode, the client rejects ANY server parameter change
|
||||
with a non-zero exit code. In loose mode, changes are accepted with a
|
||||
warning. The exact safety floor for server-decreased intervals could not
|
||||
be tested without a cooperative malicious server. See
|
||||
BLACKBOX_VERIFICATION_REPORT.md Section 19.9.
|
||||
**Result:** The safety floor is **1 second**, and it is enforced in loose mode
|
||||
as well as strict mode.
|
||||
|
||||
### 19.10 Maximum Parameter Buffer Size — PARTIALLY RESOLVED
|
||||
The test uses an ordinary, unmodified server: a server restricts the negotiated
|
||||
interval to at most one quarter of its idle timeout, so an idle timeout below
|
||||
4 seconds forces a reduction below 1 second for a client requesting 1 second.
|
||||
|
||||
**Status:** Partially tested 2026-04-28.
|
||||
| Server idle timeout | Returned interval | Client outcome |
|
||||
|---------------------|-------------------|----------------|
|
||||
| 2 s | 500 ms | Aborts: reduction below 1 s — strict **and** loose |
|
||||
| 3 s | 750 ms | Aborts: reduction below 1 s — strict **and** loose |
|
||||
| 4 s | 1 s (no reduction for a 1 s request) | Proceeds |
|
||||
|
||||
**Result:** Largest observed parameter payload was 52 bytes (all params
|
||||
including 24-character ServerFill). Upper bound estimate is ~82 bytes. The
|
||||
128-byte limit appears safe. See BLACKBOX_VERIFICATION_REPORT.md
|
||||
Section 19.10.
|
||||
A reduction to exactly 1 second (requesting 2 s against a 4 s idle timeout) is
|
||||
treated as an ordinary server restriction: rejected in strict mode, accepted in
|
||||
loose mode.
|
||||
|
||||
See `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 4.4.
|
||||
|
||||
### 19.10 Maximum Parameter Buffer Size — ✅ RESOLVED (as a local choice)
|
||||
|
||||
**Status:** Verified 2026-08-11, irtt 0.9.1, macOS arm64.
|
||||
|
||||
**Result:** There is no protocol limit on the size of a received parameter
|
||||
payload. An open request carrying over 200 bytes of parameters (80 repetitions
|
||||
of an unknown tag) was accepted and answered normally by an upstream server.
|
||||
|
||||
The largest payload a version-1 peer can legitimately *produce* remains small:
|
||||
the observed maximum from a real client was 52 bytes, and the theoretical
|
||||
maximum with all tags at their widest encodings plus a 32-byte ServerFill string
|
||||
is under 90 bytes. Allocating at least 128 bytes for the parameter buffer is a
|
||||
safe **implementation choice**, not a protocol requirement, and a receiver
|
||||
SHOULD tolerate larger payloads rather than rejecting them.
|
||||
|
||||
### 19.11 Field Ordering Verification — ✅ RESOLVED
|
||||
|
||||
@@ -1429,6 +1650,97 @@ negative value?
|
||||
This edge case requires server processing time to exceed the raw
|
||||
client-measured RTT, which is extremely unlikely under normal conditions.
|
||||
|
||||
### 19.13 Server-Side Protocol Version Enforcement — ✅ RESOLVED
|
||||
|
||||
**Status:** Verified 2026-08-11, irtt 0.9.1, macOS arm64.
|
||||
|
||||
**Result:** Upstream servers do not enforce the protocol version. Open requests
|
||||
carrying `ProtocolVersion` values of 0, 2 and −1, and requests omitting the tag
|
||||
entirely, each produced a session-creating open reply with flags `0x03`
|
||||
(Open|Reply), a non-zero token, and `ProtocolVersion = 1` in the returned
|
||||
parameters.
|
||||
|
||||
**Consequence for the client:** the mismatch check in Sections 6.2 and 9.1 is
|
||||
the only line of defence and MUST be implemented. A client that assumes the
|
||||
server will reject it will silently run a test against an incompatible peer.
|
||||
|
||||
### 19.14 Reply Length Longer Than Negotiated — ✅ RESOLVED
|
||||
|
||||
**Status:** Verified 2026-08-11, irtt 0.9.1, macOS arm64. Extended 2026-08-11
|
||||
with a negotiated-length sweep and a version comparison.
|
||||
|
||||
**Result:** Yes, in one specific, verified case. When midpoint timestamps are
|
||||
negotiated with a single clock (wall only or monotonic only), upstream 0.9.1
|
||||
servers emit both midpoint fields on the wire — wall field then monotonic field —
|
||||
which is one 8-byte field more than the negotiated parameters imply. Measured
|
||||
across all combinations of received-stats and clock with midpoint timestamps;
|
||||
confirmed at the application layer by an upstream client reporting 36-byte
|
||||
packets while receiving 44-byte datagrams, and completing normally.
|
||||
|
||||
**The excess is bounded by the ordinary length rule, not constant.** Reply length
|
||||
is the larger of the negotiated length and the field block; the extra field
|
||||
enlarges only the field block. Measured with received-stats = both, midpoint,
|
||||
wall (`normal_header` 36, `upstream_header` 44): negotiated lengths 0, 16, 32,
|
||||
36 → 44-byte replies; 37 → 44 (+7); 40 → 44 (+4); 43 → 44 (+1); 44 → 44 (0);
|
||||
45, 64, 1024, 4096 → exactly the negotiated length (0). At 4096 the midpoint
|
||||
region still contains both fields — the extra field displaces payload rather than
|
||||
extending the datagram. Any claim that upstream replies are simply "the
|
||||
negotiated length plus 8" is wrong outside the first regime. Each cell of that
|
||||
sweep is a different negotiation with a single reply length; the +7 / +4 / +1
|
||||
figures are differences between negotiations, never alternative lengths available
|
||||
within one.
|
||||
|
||||
**Scope of the requirement.** For single-clock midpoint the client MUST accept a
|
||||
reply at **exactly** `upstream_0_9_1_reply_len`. Where that value exceeds
|
||||
`compatible_reply_len` the form is identifiable by length alone and the two
|
||||
values differ by between 1 and 8 bytes — but that is one additional accepted
|
||||
length, not a `+1..+8` window, and a length strictly between the two MUST be
|
||||
rejected like any other unexpected length. That is the whole of the tolerance: it
|
||||
does **not** generalise to accepting arbitrary overlong echo replies, and the
|
||||
client MUST continue to reject replies *shorter* than `compatible_reply_len` —
|
||||
the normal compatible reply length, not merely the negotiated `Length` value —
|
||||
and to validate every other unexpectedly sized datagram strictly. It MUST NOT
|
||||
require the extra field to be present.
|
||||
|
||||
**The monotonic-only ambiguity.** Once the negotiated length reaches
|
||||
`upstream_header`, the upstream and conforming forms are the same size and are not
|
||||
distinguishable from the datagram alone. A client MUST NOT unconditionally read
|
||||
the second 8-byte region as the monotonic value whenever midpoint + monotonic is
|
||||
negotiated — against a conforming server that region is ordinary payload. Apply a
|
||||
correction only when the dual-field form is otherwise identifiable, meaning
|
||||
`upstream_0_9_1_reply_len > compatible_reply_len` and the datagram is exactly
|
||||
that length; otherwise record the value as ambiguous, and accept the packet
|
||||
either way. See the server specification, Section 11.3.1.
|
||||
|
||||
A server that emits only the negotiated fields is also fully interoperable: a
|
||||
server built to do so was driven by the upstream 0.9.1 client for wall-only,
|
||||
monotonic-only and dual-clock midpoint configurations and every run completed
|
||||
cleanly. Upstream 0.9.0 behaves that way on the wire; the dual-field form appears
|
||||
in 0.9.1 and is unchanged in the tested post-0.9.1 tree.
|
||||
|
||||
### 19.15 Received Window After Reordering or a Large Gap — ✅ RESOLVED
|
||||
|
||||
**Status:** Verified 2026-08-11, irtt 0.9.1, macOS arm64.
|
||||
|
||||
**Result:** The window is **reset to `0x1`**, not merely left unshifted, in both
|
||||
of these cases:
|
||||
|
||||
- a request whose sequence number is lower than the highest already seen
|
||||
(reordering, or a late gap fill);
|
||||
- a request whose sequence number is 64 or more ahead of the previous one.
|
||||
|
||||
The count still increments. A gap of exactly 63 preserves the older packet in
|
||||
bit 63; a gap of 64 discards everything.
|
||||
|
||||
The window value 0 was never observed; bit 0 is always set. 32-bit sequence
|
||||
wraparound is handled as ordinary modular arithmetic and does not disturb the
|
||||
window.
|
||||
|
||||
Full input → output tables are in
|
||||
`test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1, with a capture in
|
||||
`captures/server-recv-window.pcapng`. See Section 12.8 for the classification
|
||||
consequence.
|
||||
|
||||
---
|
||||
|
||||
## 20. Non-Goals
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -160,8 +160,8 @@ b9 07 05 06 06 06 07 06
|
||||
- Bytes 4-19: HMAC `ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36`
|
||||
- Bytes 20-39: Parameter payload
|
||||
|
||||
**HMAC verified independently with Python (HMAC-MD5 over entire packet
|
||||
with HMAC field zeroed).**
|
||||
**HMAC cross-checked with a separately written Python script (HMAC-MD5 over the
|
||||
entire packet with the HMAC field zeroed).**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
# IRTT Server Behavioral Vectors
|
||||
|
||||
**Source:** Black-box observation of an upstream IRTT **0.9.1 release** server on
|
||||
macOS Darwin 25.5.0 arm64, driven by a raw UDP harness.
|
||||
**Date:** 2026-08-11
|
||||
**Companion to:** `../IRTT_SERVER_PROTOCOL_SPEC.md`
|
||||
|
||||
These vectors are stated as **input → observed output**. They describe what a
|
||||
server was seen to emit for a given request sequence. They do not describe how
|
||||
any implementation produces those bytes.
|
||||
|
||||
Unless a row says otherwise, the server under test is the unmodified upstream
|
||||
0.9.1 release. Two other builds appear only in Section 5.2.3, each named
|
||||
explicitly: the 0.9.0 release, and a build of the upstream development tree six
|
||||
upstream commits past the 0.9.1 tag. Results from those builds never stand in for
|
||||
0.9.1.
|
||||
|
||||
Addresses in the accompanying captures are loopback only. Session tokens are
|
||||
server-assigned random values and differ per run; where a token appears below it
|
||||
is the value from that specific run and carries no meaning.
|
||||
|
||||
---
|
||||
|
||||
## 1. Received Window and Received Count
|
||||
|
||||
All vectors in this section were produced on a single session negotiated with
|
||||
ReceivedStats = both (3), StampAt = none (0). Each row is one echo request and
|
||||
the reply it produced. `count` is the 32-bit received count field; `window` is
|
||||
the 64-bit received window field.
|
||||
|
||||
### 1.1 Sequential, no loss
|
||||
|
||||
| # | request seq | reply count | reply window |
|
||||
|---|-------------|-------------|--------------|
|
||||
| 1 | 0 | 1 | `0x0000000000000001` |
|
||||
| 2 | 1 | 2 | `0x0000000000000003` |
|
||||
| 3 | 2 | 3 | `0x0000000000000007` |
|
||||
| 4 | 3 | 4 | `0x000000000000000f` |
|
||||
| 5 | 4 | 5 | `0x000000000000001f` |
|
||||
| 6 | 5 | 6 | `0x000000000000003f` |
|
||||
|
||||
### 1.2 One gap
|
||||
|
||||
| # | request seq | reply count | reply window | note |
|
||||
|---|-------------|-------------|--------------|------|
|
||||
| 1 | 0 | 1 | `0x0000000000000001` | |
|
||||
| 2 | 1 | 2 | `0x0000000000000003` | |
|
||||
| 3 | 3 | 3 | `0x000000000000000d` | bit 1 clear — sequence 2 missing |
|
||||
| 4 | 4 | 4 | `0x000000000000001b` | bit 2 clear — sequence 2 missing |
|
||||
|
||||
### 1.3 Multiple gaps
|
||||
|
||||
| # | request seq | reply count | reply window |
|
||||
|---|-------------|-------------|--------------|
|
||||
| 1 | 0 | 1 | `0x0000000000000001` |
|
||||
| 2 | 3 | 2 | `0x0000000000000009` |
|
||||
| 3 | 7 | 3 | `0x0000000000000091` |
|
||||
| 4 | 8 | 4 | `0x0000000000000123` |
|
||||
|
||||
### 1.4 Later gap fill — window collapses
|
||||
|
||||
| # | request seq | reply count | reply window | note |
|
||||
|---|-------------|-------------|--------------|------|
|
||||
| 1 | 0 | 1 | `0x0000000000000001` | |
|
||||
| 2 | 1 | 2 | `0x0000000000000003` | |
|
||||
| 3 | 5 | 3 | `0x0000000000000031` | gap over 2, 3, 4 |
|
||||
| 4 | 3 | 4 | **`0x0000000000000001`** | filling the gap **discards** history |
|
||||
|
||||
A late arrival does not set the corresponding historical bit. The window is
|
||||
reset to "only the current packet is known".
|
||||
|
||||
### 1.5 Duplicate sequence number
|
||||
|
||||
| # | request seq | reply count | reply window | note |
|
||||
|---|-------------|-------------|--------------|------|
|
||||
| 1 | 0 | 1 | `0x0000000000000001` | |
|
||||
| 2 | 1 | 2 | `0x0000000000000003` | |
|
||||
| 3 | 1 | 3 | `0x0000000000000003` | count advances, window unchanged |
|
||||
| 4 | 2 | 4 | `0x0000000000000007` | |
|
||||
|
||||
### 1.6 Reordering
|
||||
|
||||
| # | request seq | reply count | reply window | note |
|
||||
|---|-------------|-------------|--------------|------|
|
||||
| 1 | 0 | 1 | `0x0000000000000001` | |
|
||||
| 2 | 1 | 2 | `0x0000000000000003` | |
|
||||
| 3 | 2 | 3 | `0x0000000000000007` | |
|
||||
| 4 | 1 | 4 | **`0x0000000000000001`** | out-of-order arrival resets the window |
|
||||
| 5 | 3 | 5 | `0x0000000000000005` | rebuilds from the reset state |
|
||||
|
||||
Note row 5: the window says sequence 1 was received (bit 2) and sequence 3 was
|
||||
received (bit 0), but bit 1 is clear even though sequence 2 *was* received
|
||||
earlier. History before the reset is unrecoverable.
|
||||
|
||||
### 1.7 Strictly descending sequence numbers
|
||||
|
||||
| # | request seq | reply count | reply window |
|
||||
|---|-------------|-------------|--------------|
|
||||
| 1 | 5 | 1 | `0x0000000000000001` |
|
||||
| 2 | 4 | 2 | `0x0000000000000001` |
|
||||
| 3 | 3 | 3 | `0x0000000000000001` |
|
||||
| 4 | 2 | 4 | `0x0000000000000001` |
|
||||
| 5 | 1 | 5 | `0x0000000000000001` |
|
||||
| 6 | 0 | 6 | `0x0000000000000001` |
|
||||
|
||||
Every reply is answered; only the count carries information.
|
||||
|
||||
### 1.8 Gap at and beyond the representable window
|
||||
|
||||
| first seq | second seq | gap | second reply window |
|
||||
|-----------|------------|-----|---------------------|
|
||||
| 0 | 63 | 63 | `0x8000000000000001` — highest bit set |
|
||||
| 0 | 64 | 64 | `0x0000000000000001` |
|
||||
| 0 | 65 | 65 | `0x0000000000000001` |
|
||||
| 0 | 1000 | 1000 | `0x0000000000000001` |
|
||||
|
||||
The window represents the current packet plus 63 predecessors. A gap of exactly
|
||||
63 still records the older packet in the top bit; a gap of 64 or more discards
|
||||
all history.
|
||||
|
||||
### 1.9 Non-zero starting sequence number
|
||||
|
||||
| # | request seq | reply count | reply window |
|
||||
|---|-------------|-------------|--------------|
|
||||
| 1 | 100 | 1 | `0x0000000000000001` |
|
||||
| 2 | 101 | 2 | `0x0000000000000003` |
|
||||
| 3 | 102 | 3 | `0x0000000000000007` |
|
||||
|
||||
The first request of a session always yields window `0x1`, whatever its
|
||||
sequence number.
|
||||
|
||||
### 1.10 32-bit sequence wraparound
|
||||
|
||||
| # | request seq | reply count | reply window |
|
||||
|---|-------------|-------------|--------------|
|
||||
| 1 | 4294967293 | 1 | `0x0000000000000001` |
|
||||
| 2 | 4294967294 | 2 | `0x0000000000000003` |
|
||||
| 3 | 4294967295 | 3 | `0x0000000000000007` |
|
||||
| 4 | 0 | 4 | `0x000000000000000f` |
|
||||
| 5 | 1 | 5 | `0x000000000000001f` |
|
||||
|
||||
Wraparound is handled as ordinary modular arithmetic: crossing 2^32 − 1 → 0 is
|
||||
treated as a step of one, not as a gap of 2^32 − 1 and not as reordering.
|
||||
|
||||
Starting a session at 4294967295 and following with 0 gives `0x1` then `0x3`.
|
||||
|
||||
### 1.11 Randomized cross-check
|
||||
|
||||
The window and count rules in `../IRTT_SERVER_PROTOCOL_SPEC.md` Section 10 were
|
||||
checked against a live server over 12 sessions of 40 requests each — 480 replies
|
||||
in total — with sequence numbers drawn from a mix of in-order steps, duplicates,
|
||||
forward gaps of 2 to 80, and backward steps of 1 to 5, and with starting
|
||||
sequence numbers of 0, 1, 7, 100 and 4294967294.
|
||||
|
||||
**Result: 480 replies checked, 0 mismatches** in either the window value or the
|
||||
count value.
|
||||
|
||||
### 1.12 Count excludes dropped requests
|
||||
|
||||
Server configured with a minimum interval of 100 ms and a burst allowance of 5.
|
||||
Twelve requests sent back to back, then three more after a 600 ms pause.
|
||||
|
||||
| requests sent | replies received | counts in replies |
|
||||
|---------------|------------------|-------------------|
|
||||
| 12 back to back | 5 | 1, 2, 3, 4, 5 |
|
||||
| 3 after a pause | 3 | 6, 7, 8 |
|
||||
|
||||
Rate-limited requests produce no reply and do not advance the count.
|
||||
|
||||
---
|
||||
|
||||
## 2. Session Identity
|
||||
|
||||
Capture: `../captures/server-session-identity.pcapng`.
|
||||
|
||||
Session opened from source port A; token `88a76d2fd0cd5f32`.
|
||||
|
||||
| # | source | packet | observed |
|
||||
|---|--------|--------|----------|
|
||||
| 1 | A | open request | open reply, token assigned |
|
||||
| 2 | A | echo seq 0 | reply, count 1, window `0x1` |
|
||||
| 3 | **B** | echo seq 1, correct token | **no reply** |
|
||||
| 4 | **B** | close, correct token | **no reply**, session **not** closed |
|
||||
| 5 | A | echo seq 2 | reply, count 2, window `0x5` |
|
||||
| 6 | **C** | echo seq 0, token `1111111111111111` | **no reply** |
|
||||
| 7 | A | close | **no reply**, session removed |
|
||||
|
||||
Row 5 is the important one: the count advanced from 1 to 2, not to 3 — the
|
||||
foreign-endpoint request in row 3 was not counted. The window `0x5` reflects
|
||||
sequence 2 (bit 0) and sequence 0 (bit 2), with bit 1 clear because sequence 1
|
||||
never reached the session.
|
||||
|
||||
Row 4 confirms that a close from a foreign endpoint does not tear down the
|
||||
session.
|
||||
|
||||
Additional cases from the same class of experiment:
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| Two opens from the same source port | Two distinct tokens, both simultaneously usable |
|
||||
| Same token replayed to the other address family's listener on the same port | No reply |
|
||||
| Echo with an all-zero token | No reply |
|
||||
| Echo with the token of an already-closed session | No reply |
|
||||
| Repeated close with the same token | No reply |
|
||||
| 1000 opens from one source endpoint | 1000 distinct tokens, all usable |
|
||||
|
||||
---
|
||||
|
||||
## 3. Close Lifecycle
|
||||
|
||||
### 3.1 Client-initiated close
|
||||
|
||||
Capture: `../captures/server-close-lifecycle.pcapng`. Session token
|
||||
`eef1e3fb1ab2eb66`, negotiated ReceivedStats = both, StampAt = none.
|
||||
|
||||
| frame | direction | bytes |
|
||||
|-------|-----------|-------|
|
||||
| 1 | C → S | `14a75b01 01020280e0ba84bf030380a8d6b9070506` |
|
||||
| 2 | S → C | `14a75b03 eef1e3fb1ab2eb66 01020280e0ba84bf030380a8d6b9070506` |
|
||||
| 3 | C → S | `14a75b00 eef1e3fb1ab2eb66 00000000 00000000 0000000000000000` |
|
||||
| 4 | S → C | `14a75b02 eef1e3fb1ab2eb66 00000000 01000000 0100000000000000` |
|
||||
| 5 | C → S | `14a75b00 eef1e3fb1ab2eb66 01000000 00000000 0000000000000000` |
|
||||
| 6 | S → C | `14a75b02 eef1e3fb1ab2eb66 01000000 02000000 0300000000000000` |
|
||||
| 7 | C → S | `14a75b00 eef1e3fb1ab2eb66 02000000 00000000 0000000000000000` |
|
||||
| 8 | S → C | `14a75b02 eef1e3fb1ab2eb66 02000000 03000000 0700000000000000` |
|
||||
| 9 | C → S | `14a75b04 eef1e3fb1ab2eb66` — close request |
|
||||
| 10 | C → S | `14a75b04 eef1e3fb1ab2eb66` — repeated close |
|
||||
| 11 | C → S | `14a75b00 eef1e3fb1ab2eb66 09000000 …` — echo after close |
|
||||
|
||||
Frames 9, 10 and 11 receive **no response**. Frame 9 removes the session; 10 and
|
||||
11 are dropped as unknown-token packets. The capture ends with three unanswered
|
||||
client packets, which is the complete and correct close exchange for
|
||||
protocol version 1.
|
||||
|
||||
Close request layout: magic (3) + flags `0x04` (1) + token (8) = 12 bytes. With
|
||||
authentication: magic (3) + flags `0x0c` (1) + MAC (16) + token (8) = 28 bytes.
|
||||
|
||||
### 3.2 Server-initiated close
|
||||
|
||||
Capture: `../captures/server-close.pcapng`. Server configured with a maximum
|
||||
test duration of 2 s; client requested 600 s and was restricted to 2 s. Requests
|
||||
sent every ~250 ms.
|
||||
|
||||
| frame | direction | bytes (abridged) | note |
|
||||
|-------|-----------|------------------|------|
|
||||
| 2 | S → C | `14a75b03 f0592d9fb129558c 010202 80d0acf30e 03…` | duration restricted to 2 s |
|
||||
| 34 | S → C | `14a75b02 … 0f000000 10000000 ffff000000000000` | ordinary reply, seq 15 |
|
||||
| 35 | C → S | `14a75b00 … 10000000 …` | echo seq 16 |
|
||||
| **36** | S → C | `14a75b06 f0592d9fb129558c 10000000 11000000 ffff010000000000` | **flags `0x06` = Reply\|Close** |
|
||||
| 37 | C → S | `14a75b00 … 63000000 …` | echo seq 99 — **no reply** |
|
||||
|
||||
Frame 36 is a complete, ordinary echo reply — correct sequence number (16),
|
||||
correct token, count 17, window `0x1ffff` — with the Close flag additionally
|
||||
set. The session is gone from that moment; frame 37 is unanswered.
|
||||
|
||||
Timing measurements of the deadline, from the **first** echo request of the
|
||||
session:
|
||||
|
||||
| configured maximum duration | close flag observed at |
|
||||
|-----------------------------|------------------------|
|
||||
| 1 s | 3.01 s |
|
||||
| 2 s | 4.06 s |
|
||||
| 2 s (second run) | 4.10 s |
|
||||
|
||||
The deadline is the configured maximum duration plus a 2-second grace period.
|
||||
|
||||
### 3.3 Observed upstream client reaction to a close-flagged reply
|
||||
|
||||
Driving an upstream 0.9.1 client against a server that sets the Close flag from
|
||||
sequence 2 onward:
|
||||
|
||||
```
|
||||
seq=0 recorded
|
||||
seq=1 recorded
|
||||
seq=2 reply carries Close -> run terminates
|
||||
```
|
||||
|
||||
The client reports a "server closed connection" condition, prints the statistics
|
||||
for sequences 0 and 1 only — the close-flagged reply is **not** recorded — and
|
||||
exits with a success status.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open Negotiation
|
||||
|
||||
### 4.1 Protocol version is not rejected
|
||||
|
||||
Server default configuration. Each row is an open request carrying only the
|
||||
listed ProtocolVersion value.
|
||||
|
||||
| requested ProtocolVersion | server response | reply parameter payload |
|
||||
|---------------------------|-----------------|-------------------------|
|
||||
| 1 | open reply, session created | `0102` (version 1) |
|
||||
| 0 | open reply, session created | `0102` (version 1) |
|
||||
| 2 | open reply, session created | `0102` (version 1) |
|
||||
| −1 | open reply, session created | `0102` (version 1) |
|
||||
| tag absent entirely | open reply, session created | `0102` (version 1) |
|
||||
|
||||
In every case the reply flags were `0x03` (Open\|Reply) with the Close flag
|
||||
**clear** and a non-zero token. Reply length 14 bytes: magic (3) + flags (1) +
|
||||
token (8) + `0102`.
|
||||
|
||||
### 4.2 Parameter payload acceptance
|
||||
|
||||
| open request parameter payload | response |
|
||||
|-------------------------------|----------|
|
||||
| empty | open reply, 14 bytes |
|
||||
| tag 2 only, no value bytes | no reply |
|
||||
| tag 2 with an incomplete multi-byte varint | no reply |
|
||||
| version 1 + duration 0 | no reply |
|
||||
| version 1 + duration −1 | no reply |
|
||||
| version 1 + interval −5 | no reply |
|
||||
| version 1 + ReceivedStats 4 | no reply |
|
||||
| version 1 + ReceivedStats −1 | no reply |
|
||||
| version 1 + StampAt 5 | no reply |
|
||||
| version 1 + StampAt 4 + Clock 3 | open reply |
|
||||
| version 1 + Clock 0 | no reply |
|
||||
| version 1 + Clock 4 | no reply |
|
||||
| version 1 + unknown tag 42 | open reply, tag ignored |
|
||||
| version 1 + unknown tag 200 | open reply, tag ignored |
|
||||
| version 1 + 80 repetitions of unknown tag 42 (~200 bytes) | open reply, all ignored |
|
||||
| version 1 + ServerFill declared 40 bytes | no reply |
|
||||
| version 1 + ServerFill declaring 10 bytes with only 2 present | no reply |
|
||||
| 50 bytes of arbitrary data | no reply |
|
||||
|
||||
### 4.3 No-test open
|
||||
|
||||
| direction | bytes |
|
||||
|-----------|-------|
|
||||
| C → S | `14a75b05` + parameter payload — flags Open\|Close |
|
||||
| S → C | `14a75b07 0000000000000000` + restricted parameters — flags Open\|Reply\|Close, **zero token** |
|
||||
|
||||
With authentication the reply flags are `0x0f` and the reply carries a valid MAC.
|
||||
The zero token is not usable: subsequent echo and close requests bearing it get
|
||||
no reply.
|
||||
|
||||
### 4.4 Interval restriction against the idle timeout
|
||||
|
||||
Client requests an interval of one hour; server has no configured minimum
|
||||
interval.
|
||||
|
||||
| server idle timeout | negotiated interval |
|
||||
|---------------------|---------------------|
|
||||
| 4 s | 1 s |
|
||||
| 8 s | 2 s |
|
||||
| 20 s | 5 s |
|
||||
| 40 s | 10 s |
|
||||
| 60 s | 15 s |
|
||||
|
||||
Client-side floor, measured with an upstream 0.9.1 client requesting a 1 s
|
||||
interval:
|
||||
|
||||
| server idle timeout | server-returned interval | client outcome |
|
||||
|---------------------|--------------------------|----------------|
|
||||
| 2 s | 500 ms | aborts — reduction below 1 s, in strict **and** loose mode |
|
||||
| 3 s | 750 ms | aborts — reduction below 1 s, in strict **and** loose mode |
|
||||
| 4 s | 1 s (no reduction) | proceeds |
|
||||
|
||||
Requesting a 2 s interval against a 4 s idle timeout produces a reduction to
|
||||
exactly 1 s: rejected in strict mode as an ordinary restriction, accepted in
|
||||
loose mode.
|
||||
|
||||
### 4.5 Timestamp allowance
|
||||
|
||||
Requested StampAt → negotiated StampAt, by server timestamp allowance:
|
||||
|
||||
| requested | dual | single | none |
|
||||
|-----------|------|--------|------|
|
||||
| 0 (none) | 0 | 0 | 0 |
|
||||
| 1 (send) | 1 | 1 | 0 |
|
||||
| 2 (receive) | 2 | 2 | 0 |
|
||||
| 3 (both) | 3 | **4** | 0 |
|
||||
| 4 (midpoint) | 4 | 4 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Reply Length
|
||||
|
||||
### 5.1 Length as a function of negotiated fields
|
||||
|
||||
Reply lengths, unauthenticated, measured across every combination. `S` is
|
||||
ReceivedStats, `A` is StampAt, `C` is Clock.
|
||||
|
||||
| S | A | C | reply length |
|
||||
|---|---|---|--------------|
|
||||
| 0 | 0 | any | 16 |
|
||||
| 1 | 0 | any | 20 |
|
||||
| 2 | 0 | any | 24 |
|
||||
| 3 | 0 | any | 28 |
|
||||
| 3 | 1 or 2 | 1 or 2 | 36 |
|
||||
| 3 | 1 or 2 | 3 | 44 |
|
||||
| 3 | 3 | 1 or 2 | 44 |
|
||||
| 3 | 3 | 3 | 60 |
|
||||
| 3 | 4 | 1 or 2 | **44** — see below |
|
||||
| 3 | 4 | 3 | 44 |
|
||||
|
||||
Add 16 bytes to every figure when authentication is in use.
|
||||
|
||||
### 5.2 Dual-field midpoint wire representation
|
||||
|
||||
For StampAt = 4 (midpoint), upstream 0.9.1 emits **two** 8-byte timestamp fields
|
||||
— the midpoint wall field followed by the midpoint monotonic field, in that order
|
||||
— for every negotiated Clock, occupying a fixed 16-byte region. With a single
|
||||
negotiated clock this is one field more than the negotiated parameters imply.
|
||||
These rows record bytes observed on the wire and nothing else. All vectors below
|
||||
at negotiated length 0.
|
||||
|
||||
| S | A | C | expected from negotiation | observed |
|
||||
|---|---|---|---------------------------|----------|
|
||||
| 0 | 4 | 1 | 24 | **32** |
|
||||
| 0 | 4 | 2 | 24 | **32** |
|
||||
| 0 | 4 | 3 | 32 | 32 |
|
||||
| 1 | 4 | 1 | 28 | **36** |
|
||||
| 1 | 4 | 2 | 28 | **36** |
|
||||
| 2 | 4 | 1 | 32 | **40** |
|
||||
| 2 | 4 | 2 | 32 | **40** |
|
||||
| 3 | 4 | 1 | 36 | **44** |
|
||||
| 3 | 4 | 2 | 36 | **44** |
|
||||
| 3 | 4 | 3 | 44 | 44 |
|
||||
|
||||
Raw reply for S=0, A=4, C=1 (wall only), 32 bytes:
|
||||
|
||||
```
|
||||
14 a7 5b 02 <token 8> 00 00 00 00
|
||||
a4 c1 74 90 47 83 ca 18 <- midpoint wall, 1786384545595310500
|
||||
da 45 a2 3b 00 00 00 00 <- midpoint monotonic, 1000490458
|
||||
```
|
||||
|
||||
The second field was not negotiated. An upstream 0.9.1 client run in this
|
||||
configuration reports "packet length: 36 bytes" while receiving 44-byte
|
||||
datagrams, and completes normally.
|
||||
|
||||
No other StampAt value deviates. Measured at negotiated length 0, S=3: `A=1` and
|
||||
`A=2` give 36 bytes for C=1 or C=2 and 44 for C=3; `A=3` gives 44 for C=1 or C=2
|
||||
and 60 for C=3 — all exactly as the negotiated layout implies.
|
||||
|
||||
### 5.2.1 The excess is bounded by the negotiated length
|
||||
|
||||
The extra field enlarges the field block, not every reply. Reply length remains
|
||||
the larger of the negotiated length and the field block (Section 5.3):
|
||||
|
||||
```
|
||||
normal_header = negotiated field block
|
||||
upstream_header = normal_header + one 8-byte timestamp field
|
||||
|
||||
compatible_reply_len = max(negotiated_length, normal_header)
|
||||
upstream_0_9_1_reply_len = max(negotiated_length, upstream_header)
|
||||
```
|
||||
|
||||
Sweep for S=3, A=4, C=1 — `normal_header` 36, `upstream_header` 44:
|
||||
|
||||
| negotiated length | 0 | 16 | 32 | 36 | 37 | 40 | 43 | 44 | 45 | 48 | 64 | 128 | 1024 | 4096 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| reply datagram | 44 | 44 | 44 | 44 | 44 | 44 | 44 | 44 | 45 | 48 | 64 | 128 | 1024 | 4096 |
|
||||
| excess | +8 | +8 | +8 | +8 | +7 | +4 | +1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||
|
||||
Same pattern for the other received-stats settings, with the two minima at 24/32
|
||||
(S=0), 28/36 (S=1) and 32/40 (S=2). The C=3 control case shows zero excess at
|
||||
every negotiated length.
|
||||
|
||||
At negotiated length 4096 the midpoint region still holds both fields, followed
|
||||
by fill — the extra field displaces payload rather than extending the datagram.
|
||||
The excess is therefore **+8**, then **+7…+1**, then **0**, depending on the
|
||||
negotiated length. There is no negotiated length at which the reply is
|
||||
universally "negotiated length + 8".
|
||||
|
||||
Read that row across columns, not within a column. Each column is a separate
|
||||
negotiation whose upstream reply has exactly one length,
|
||||
`upstream_0_9_1_reply_len`. The +8 / +7…+1 / 0 figures are the values the
|
||||
difference takes across different negotiations; no single negotiation produces a
|
||||
range of lengths, and nothing here says an intermediate length between
|
||||
`compatible_reply_len` and `upstream_0_9_1_reply_len` was ever emitted or should
|
||||
be accepted.
|
||||
|
||||
**Consequence for decoding.** In the zero-excess regime
|
||||
(`upstream_0_9_1_reply_len == compatible_reply_len`) the upstream form
|
||||
`[wall][mono][payload…]` and a conforming single-clock form `[mono][payload…]`
|
||||
produce datagrams of identical length, and the vectors above provide no way to
|
||||
tell them apart. That ambiguity is recorded, not resolved.
|
||||
|
||||
### 5.2.2 Reached without requesting midpoint
|
||||
|
||||
Server restricted to a single timestamp, S=3, negotiated length 0:
|
||||
|
||||
| requested A/C | negotiated A/C | expected from negotiation | observed |
|
||||
|---------------|----------------|---------------------------|----------|
|
||||
| 3 / 1 | 4 / 1 | 36 | **44** |
|
||||
| 3 / 2 | 4 / 2 | 36 | **44** |
|
||||
| 3 / 3 | 4 / 3 | 44 | 44 |
|
||||
| 4 / 1 | 4 / 1 | 36 | **44** |
|
||||
|
||||
### 5.2.3 Version comparison
|
||||
|
||||
Negotiated length 0, S=3:
|
||||
|
||||
| build | A=4, C=1 | A=4, C=2 | A=4, C=3 |
|
||||
|-------|----------|----------|----------|
|
||||
| 0.9.0 release | 36, one field | 36, one field (process-relative value) | 44, two fields |
|
||||
| 0.9.1 release | **44, two fields** | **44, two fields** | 44, two fields |
|
||||
| tested post-0.9.1 tree (six upstream commits past the 0.9.1 tag) | **44, two fields** | **44, two fields** | 44, two fields |
|
||||
|
||||
The 0.9.0 release emits exactly the negotiated fields; 0.9.1 emits the dual-field
|
||||
form; the tested post-0.9.1 tree behaves like 0.9.1. The six post-0.9.1 commits
|
||||
are post-release work and are not part of the 0.9.1 release.
|
||||
|
||||
A contaminated-side server emitting **only** the negotiated midpoint field was
|
||||
driven by an upstream 0.9.1 client for wall-only, monotonic-only and dual-clock
|
||||
midpoint configurations, and the client completed every run cleanly. This is
|
||||
contaminated-side consistency validation; the black-box part of it is the
|
||||
client's acceptance of the conforming form.
|
||||
|
||||
### 5.3 Negotiated length versus actual reply length
|
||||
|
||||
Server bound to an interface with a 1500-byte MTU, ReceivedStats = none,
|
||||
StampAt = none (16-byte field block).
|
||||
|
||||
| requested length | negotiated length returned | reply datagram |
|
||||
|------------------|----------------------------|----------------|
|
||||
| 0 | 0 | 16 |
|
||||
| 10 | 10 | 16 |
|
||||
| 16 | 16 | 16 |
|
||||
| 17 | 17 | 17 |
|
||||
| 1000 | 1000 | 1000 |
|
||||
| 1400 | 1400 | 1400 |
|
||||
| 1500 | 1500 | 1500 |
|
||||
| 1501 | 1501 | **1500** |
|
||||
| 2000 | 2000 | **1500** |
|
||||
| 3000 | 3000 | **1500** |
|
||||
| 8000 | 8000 | **1500** |
|
||||
|
||||
The negotiated value returned to the client is not clamped; the datagram is.
|
||||
|
||||
A session negotiating a length of 0 or 20 with full statistics and dual
|
||||
timestamps on both clocks produced 60-byte replies — the field block wins when
|
||||
it is larger than the negotiated length.
|
||||
|
||||
### 5.4 Request length versus reply length
|
||||
|
||||
| negotiated length | request datagram | reply datagram |
|
||||
|-------------------|------------------|----------------|
|
||||
| 16 | 216 (200 trailing bytes) | 16 |
|
||||
| 64 | 16 | 64 |
|
||||
| 64 | 32 | 64 |
|
||||
| 64 | 64 | 64 |
|
||||
|
||||
The reply length depends only on the negotiation, never on the request.
|
||||
|
||||
### 5.5 Maximum request length policy
|
||||
|
||||
Server configured with a maximum packet length of 64; client requested 200 and
|
||||
was restricted to 64.
|
||||
|
||||
| request datagram | result |
|
||||
|------------------|--------|
|
||||
| 16 | reply, 64 bytes |
|
||||
| 32 | reply, 64 bytes |
|
||||
| 64 | reply, 64 bytes |
|
||||
| 65 | **no reply** |
|
||||
| 100 | **no reply** |
|
||||
| open request larger than 64 | **accepted**, open reply sent |
|
||||
|
||||
Server configured with a maximum packet length of 10 (below the minimum field
|
||||
block): the session opens and negotiates a length of 10, and every 16-byte echo
|
||||
request is dropped.
|
||||
|
||||
---
|
||||
|
||||
## 6. Server Fill
|
||||
|
||||
### 6.1 Default fill bytes and pattern phase
|
||||
|
||||
Default-configuration server, ReceivedStats = none, StampAt = none, so the
|
||||
payload begins at offset 16.
|
||||
|
||||
| negotiated length | reply payload |
|
||||
|-------------------|---------------|
|
||||
| 17 | `69` |
|
||||
| 20 | `72 74 74 69` |
|
||||
| 24 | `72 74 74 69 72 74 74 69` |
|
||||
| 48 | `72 74 74 69` repeated 8 times |
|
||||
|
||||
Four consecutive replies on one session with a 7-byte payload:
|
||||
|
||||
```
|
||||
seq 0: 72 74 74 69 72 74 74
|
||||
seq 1: 69 72 74 74 69 72 74
|
||||
seq 2: 74 69 72 74 74 69 72
|
||||
seq 3: 74 74 69 72 74 74 69
|
||||
```
|
||||
|
||||
The four-byte pattern `69 72 74 74` is emitted as a continuous stream; its phase
|
||||
is not reset per packet, and it carries over between sessions on the same
|
||||
listener.
|
||||
|
||||
### 6.2 Fill descriptor negotiation
|
||||
|
||||
Default allow-list (random fills only), negotiated length 32 so the payload is
|
||||
16 bytes:
|
||||
|
||||
| requested descriptor | negotiated descriptor returned | payload |
|
||||
|----------------------|-------------------------------|---------|
|
||||
| `rand` | `rand` | random, differs per packet |
|
||||
| `none` | `pattern:69727474` | `72747469…` |
|
||||
| `pattern:aabb` | `pattern:69727474` | `72747469…` |
|
||||
| `pattern:69727474` | `pattern:69727474` | `72747469…` |
|
||||
| `bogus` | `pattern:69727474` | `72747469…` |
|
||||
| 32-character string | `pattern:69727474` | `72747469…` |
|
||||
| 33-character string | — | **open dropped** |
|
||||
|
||||
Permissive allow-list:
|
||||
|
||||
| requested descriptor | negotiated descriptor returned | payload |
|
||||
|----------------------|-------------------------------|---------|
|
||||
| `rand` | `rand` | random |
|
||||
| `pattern:aabb` | `pattern:aabb` | `aabbaabbaabbaabb…` |
|
||||
| `pattern:00` | `pattern:00` | `0000000000000000…` |
|
||||
| `pattern:ff00` | `pattern:ff00` | `ff00ff00ff00ff00…` |
|
||||
| `pattern:zz` | `pattern:69727474` | `69727474…` (invalid hex, falls back) |
|
||||
| `none` | `none` | **unspecified residual bytes** |
|
||||
| `pattern:` | — | **server terminated** |
|
||||
|
||||
Empty allow-list: every requested descriptor, including `rand`, is answered with
|
||||
`pattern:69727474`.
|
||||
|
||||
### 6.3 The refused descriptor does not describe the payload
|
||||
|
||||
Two servers with different configured fills, default allow-list (`rand` only),
|
||||
negotiated length 32 so the payload is 16 bytes:
|
||||
|
||||
| Server fill | Requested descriptor | Negotiated descriptor returned | Payload |
|
||||
|-------------|----------------------|-------------------------------|---------|
|
||||
| random | none | absent | `25ae387a787d641d82eb514f2bfca15f` (random) |
|
||||
| random | `pattern:aabb` | `pattern:69727474` | `29ce866b6d192c74376e7161dd71975d` (random) |
|
||||
| random | `rand` | `rand` | random |
|
||||
| `pattern:abcd` | none | absent | `abcdabcdabcdabcdabcdabcdabcdabcd` |
|
||||
| `pattern:abcd` | `pattern:aabb` | `pattern:69727474` | `abcdabcdabcdabcdabcdabcdabcdabcd` |
|
||||
| `pattern:abcd` | `rand` | `rand` | random |
|
||||
|
||||
On refusal, the descriptor returned to the client is a **fixed default string**,
|
||||
not a description of the bytes the server actually sends. A client must not
|
||||
predict payload content from it.
|
||||
|
||||
### 6.4 No-fill payload content
|
||||
|
||||
Server configured with no fill and a permissive allow-list, negotiated length 32
|
||||
(16-byte payload):
|
||||
|
||||
| request payload | reply payload |
|
||||
|-----------------|---------------|
|
||||
| `000102…0f` (16 bytes) | `000102…0f` — the request's own bytes |
|
||||
| none (16-byte request) | `000102…0f` — **residue from the previous packet** |
|
||||
|
||||
On a `none` fill negotiated through the descriptor, one observed 16-byte payload
|
||||
was `c0cbacf6220380897a044009 046e6f6e`, whose trailing bytes are a fragment of
|
||||
the preceding open reply's parameter payload. Payload content under no-fill is
|
||||
unspecified and may contain data from unrelated traffic.
|
||||
|
||||
---
|
||||
|
||||
## 7. Authentication
|
||||
|
||||
Server configured with key `correctkey`.
|
||||
|
||||
| request | result |
|
||||
|---------|--------|
|
||||
| Open, flags `0x09`, valid MAC | Open reply, flags `0x0b`, valid MAC, 43 bytes |
|
||||
| Echo, flags `0x08`, valid MAC | Echo reply, flags `0x0a`, valid MAC, 44 bytes |
|
||||
| Close, flags `0x0c`, valid MAC | Session removed, **no reply** |
|
||||
| No-test open, flags `0x0d`, valid MAC | Reply flags `0x0f`, zero token, valid MAC |
|
||||
| Open with no HMAC flag and no MAC field | no reply |
|
||||
| Echo with no HMAC flag and no MAC field | no reply |
|
||||
| Close with no HMAC flag and no MAC field | no reply, **session survives** |
|
||||
| Open with MAC computed under a different key | no reply |
|
||||
| Echo with MAC computed under a different key | no reply |
|
||||
| Echo with one bit flipped in the MAC | no reply |
|
||||
| Echo with the MAC field all zeros | no reply |
|
||||
| Echo with the MAC field truncated to 8 bytes | no reply |
|
||||
| 19-byte datagram (too short for the MAC field) | no reply |
|
||||
| 20-byte datagram, flags `0x08`, valid MAC, nothing else | no reply |
|
||||
| Valid MAC but the HMAC flag cleared in flags | no reply |
|
||||
| MAC recomputed with the flag cleared, flag cleared | no reply |
|
||||
|
||||
Against a server with **no** key, any request with the HMAC flag set gets no
|
||||
reply.
|
||||
|
||||
**Session integrity across failures.** After the eight failing cases above were
|
||||
sent to a live session, the next correctly authenticated echo was served
|
||||
normally and the received count had advanced only for the accepted packets.
|
||||
|
||||
**Reply authentication is mandatory.** A contaminated-side server that
|
||||
authenticated its open reply correctly but omitted the HMAC flag from echo
|
||||
replies caused an upstream 0.9.1 client to discard every echo reply and terminate
|
||||
abnormally with zero packets received. The observation that matters is the
|
||||
*client's* reaction, which is black-box; the server was only the stimulus.
|
||||
|
||||
---
|
||||
|
||||
## 8. Idle Expiry
|
||||
|
||||
Server configured with a 2-second idle timeout. Each trial: open, one echo to
|
||||
start the idle clock, wait, then two echoes back to back.
|
||||
|
||||
| gap after the last accepted echo | first request after the gap | the request after that |
|
||||
|---|---|---|
|
||||
| 5.0 s | answered | answered |
|
||||
| 6.5 s | answered | answered |
|
||||
| 6.9 s | answered | answered |
|
||||
| 7.1 s | answered | **dropped** |
|
||||
| 7.5 s | answered | **dropped** |
|
||||
| 9.0 s | answered | **dropped** |
|
||||
|
||||
Deadline = idle timeout + 5 s. The first request past the deadline is still
|
||||
answered, and the session is released while handling it.
|
||||
|
||||
**A session that has never carried an echo request does not expire.** With the
|
||||
same 2-second timeout, a session left completely idle for 9 seconds after open
|
||||
was served normally on its first echo request.
|
||||
|
||||
With the idle timeout disabled, a session idle for 9 seconds was served normally
|
||||
and remained usable.
|
||||
|
||||
---
|
||||
|
||||
## 9. Malformed Packet Admission
|
||||
|
||||
Session established; probes sent on the same socket. Prefix used:
|
||||
`14a75b00` + token + `00000000`.
|
||||
|
||||
| probe | result |
|
||||
|-------|--------|
|
||||
| 0–3 byte datagram | no reply |
|
||||
| 4–11 byte datagram (flags `0x00`) | no reply |
|
||||
| 12–15 byte datagram (flags `0x00`) | no reply |
|
||||
| 16-byte datagram (flags `0x00`) | **reply, 28 bytes** |
|
||||
| magic `15a75b` | no reply |
|
||||
| magic `000000` | no reply |
|
||||
| flags `0x10`, `0x20`, `0x40`, `0x80`, `0xf0`, `0xff`, `0x1f` | no reply |
|
||||
| flags `0x02`, `0x03`, `0x06`, `0x07`, `0x0a` (Reply set) | no reply |
|
||||
| flags `0x09` (Open\|HMAC) to a non-authenticating server | no reply |
|
||||
| flags `0x01` (Open) | open reply, new session |
|
||||
| flags `0x05` (Open\|Close) | open reply, zero token |
|
||||
| echo-shaped datagram with flags `0x01` | treated as an open; the token bytes are parsed as parameters |
|
||||
| close with 20 trailing bytes | no reply, session removed |
|
||||
|
||||
A 12-byte datagram with flags `0x04` is a valid close request; a 12-byte
|
||||
datagram with flags `0x00` is too short to be an echo request and is dropped.
|
||||
|
||||
The Open flag decides *interpretation*, not admission. For an otherwise
|
||||
admissible datagram it wins over Close (`0x05` is the no-test open form) and over
|
||||
an echo-shaped body (the token/sequence bytes are read as Open parameter data).
|
||||
It does not rescue a datagram from an independent rejection: `0x03` (Open|Reply)
|
||||
is dropped for the Reply flag, `0x09` (Open|HMAC) to a non-authenticating server
|
||||
is dropped for the authentication mismatch, `0x1f` is dropped for the undefined
|
||||
flag bits, and an Open whose parameter payload is malformed or out of range is
|
||||
dropped as well (Section 4.2).
|
||||
|
||||
---
|
||||
|
||||
## 10. DSCP Negotiation and Application
|
||||
|
||||
The values below are **raw IP TOS / Traffic Class byte** values, which is what
|
||||
the wire parameter carries — not 6-bit codepoints. Expedited Forwarding is
|
||||
therefore 184 (0xb8) in this table, not 46.
|
||||
|
||||
| requested DSCP | negotiated value returned | echo replies |
|
||||
|----------------|---------------------------|--------------|
|
||||
| 0 | absent (0) | delivered |
|
||||
| 46 | 46 | delivered |
|
||||
| 184 | 184 | delivered |
|
||||
| 255 | 255 | delivered |
|
||||
| 256 | 256 | **all dropped** |
|
||||
| 300 | 300 | **all dropped** |
|
||||
| −5 | −5 | delivered |
|
||||
|
||||
With DSCP disallowed by server policy, every requested value is returned as
|
||||
absent (0) and replies are delivered normally.
|
||||
|
||||
A value the server cannot apply to its socket is still accepted during
|
||||
negotiation; the session then opens successfully and answers nothing.
|
||||
|
||||
---
|
||||
|
||||
## 11. Captures
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `../captures/server-recv-window.pcapng` | One session exercising sequential, gap, duplicate, reorder and far-gap sequence numbers |
|
||||
| `../captures/server-close-lifecycle.pcapng` | Client-initiated close, repeated close, and an echo after close — none answered |
|
||||
| `../captures/server-close.pcapng` | Server-initiated close via the maximum-duration limit; frame 36 carries flags `0x06` |
|
||||
| `../captures/server-session-identity.pcapng` | Foreign-source-port echo and close, unknown token, and the original endpoint continuing to be served |
|
||||
|
||||
All captures are loopback traffic with the server on a fixed port and the client
|
||||
on an ephemeral port.
|
||||
|
||||
**Capture container metadata.** Every `.pcapng` under `../captures/` was reviewed
|
||||
and its container rewritten so that it carries no capture comment, no
|
||||
section/interface description, no host, user or capture-machine identification,
|
||||
no capture filter string, and no filesystem path — only the packet records and
|
||||
the rewriting tool's own version string. Packet bytes and timestamps were **not**
|
||||
altered: for each file, the full frame hexdump and the per-frame epoch timestamps
|
||||
were verified byte-identical before and after the rewrite.
|
||||
Reference in new issue
Block a user