Files
irtt-rs/docs/protocol/BLACKBOX_VERIFICATION_REPORT.md
T

1184 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IRTT Black-Box Verification Report
**Date:** 2026-04-28
**irtt version:** 0.9.1 (protocol version 1, json format version 1)
**OS:** macOS Darwin 25.3.0 arm64 (Apple Silicon)
**tshark version:** TShark (Wireshark) 4.6.4
**Loopback interface:** lo0
---
## Environment Check
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
$ tshark --version
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
### 19.1 Open Request Field Layout — RESOLVED
**Question:** Does the open request include a connection token field (zeroed)?
**Test setup:** Captured a full session including open exchange.
**Command:**
```
irtt server -b 127.0.0.1:2112
irtt client -d 3s -i 1s 127.0.0.1:2112 -q
```
**Capture:** `captures/full-session.pcapng`
**Observed open request (frame 1):**
```
14 a7 5b 01 01 02 02 80 f8 82 ad 16 03 80 a8 d6
b9 07 05 06 06 06 07 06
```
- Bytes 0-2: Magic `14 a7 5b`
- Byte 3: Flags `01` (Open)
- Bytes 4+: Parameter payload (no connection token)
**Observed open reply (frame 2):**
```
14 a7 5b 03 13 52 71 87 ab b6 96 78 01 02 02 80
f8 82 ad 16 03 80 a8 d6 b9 07 05 06 06 06 07 06
```
- Bytes 0-2: Magic
- Byte 3: Flags `03` (Open | Reply)
- Bytes 4-11: Connection token `13 52 71 87 ab b6 96 78`
- Bytes 12+: Parameter payload
**Conclusion:** The open request does NOT include a connection token field.
The layout is: magic (3) + flags (1) + [HMAC (16) if applicable] + params.
This is a **normative requirement** — the connection token is absent in open
requests because it has not yet been assigned.
---
### 19.2 Minimum Packet Length — RESOLVED
**Question:** What is the minimum echo packet length for various
configurations?
**Test setup:** Multiple sessions with different --stats and --tstamp
settings, all with -l 0 (minimum length).
**Commands:**
```
irtt client -d 1s -i 1s --tstamp=none --stats=none 127.0.0.1
irtt client -d 1s -i 1s --tstamp=send --clock=wall --stats=count 127.0.0.1
irtt client -d 1s -i 1s --tstamp=receive --clock=monotonic --stats=window 127.0.0.1
irtt client -d 1s -i 1s --tstamp=midpoint --clock=both --stats=both 127.0.0.1
irtt client -d 1s -i 1s --tstamp=both --clock=both --stats=both 127.0.0.1
```
**Capture:** `captures/tstamp-none.pcapng`
**Observed minimum packet lengths (echo request/reply):**
| Stats | StampAt | Clock | Packet Length | Breakdown |
|-------|---------|-------|---------------|-----------|
| none | none | - | 16 bytes | magic(3)+flags(1)+token(8)+seq(4) |
| count | send | wall | 28 bytes | +recv_count(4)+send_wall(8) |
| window | receive | mono | 32 bytes | +recv_window(8)+recv_mono(8) |
| both | midpoint | both | 44 bytes | +recv_count(4)+recv_window(8)+mid_wall(8)+mid_mono(8) |
| both | both | both | 60 bytes | +recv_count(4)+recv_window(8)+recv_wall(8)+recv_mono(8)+send_wall(8)+send_mono(8) |
With HMAC, add 16 bytes (observed: 76 bytes for default+HMAC).
**Conclusion:** The minimum packet length formula is:
```
min_length = 4 (header) + 8 (token) + 4 (seq)
+ [16 if HMAC]
+ [4 if recv_count (stats=count or both)]
+ [8 if recv_window (stats=window or both)]
+ [8 per timestamp field present]
```
Timestamp field count depends on StampAt and Clock:
- none: 0 fields
- send/wall or receive/wall: 1 field (8 bytes)
- send/mono or receive/mono: 1 field (8 bytes)
- send/both or receive/both: 2 fields (16 bytes)
- both/wall: 2 fields (16 bytes)
- both/mono: 2 fields (16 bytes)
- both/both: 4 fields (32 bytes)
- midpoint/wall: 1 field (8 bytes)
- midpoint/mono: 1 field (8 bytes)
- midpoint/both: 2 fields (16 bytes)
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
**Question:** Is the varint encoding byte-compatible with protobuf-style
LEB128/zigzag?
**Test setup:** Decoded parameters from captured open request packets and
verified against known values.
**Observed parameter encodings:**
| Parameter | Value | Zigzag Encoded | LEB128 Bytes |
|-----------|-------|---------------|--------------|
| ProtocolVersion=1 | 1 | 2 | `02` |
| Duration=3s | 3000000000 | 6000000000 | `80 f8 82 ad 16` |
| Duration=1s | 1000000000 | 2000000000 | `80 a8 d6 b9 07` |
| Interval=1s | 1000000000 | 2000000000 | `80 a8 d6 b9 07` |
| Length=1472 | 1472 | 2944 | `80 17` |
| ReceivedStats=Both | 3 | 6 | `06` |
| StampAt=Both | 3 | 6 | `06` |
| Clock=Both | 3 | 6 | `06` |
| DSCP=0xb8 | 184 | 368 | `f0 02` |
| ServerFill (tag) | 9 | n/a (uvarint) | `09` |
| ServerFill (length) | 24 | n/a (uvarint) | `18` |
All decoded correctly using standard protobuf-style zigzag + LEB128.
**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.
---
### 19.5 HMAC Computation Scope — RESOLVED
**Question:** Is the HMAC computed over the entire packet (including payload)
with the HMAC field zeroed?
**Test setup:** Captured HMAC session, extracted packet bytes, and recomputed
HMAC-MD5 with a separately written Python script.
**Commands:**
```
irtt server -b 127.0.0.1:2114 --hmac=testkey
irtt client -d 2s -i 1s --hmac=testkey 127.0.0.1:2114 -q
```
**Capture:** `captures/hmac-session.pcapng`
**Verification (Python):**
```python
import hmac, hashlib
key = b"testkey"
# Zero HMAC field (bytes 4-19), compute over entire packet
computed = hmac.new(key, packet_with_zeroed_hmac, hashlib.md5).digest()
```
**Results:**
| Packet Type | Captured HMAC | Computed HMAC | Match |
|-------------|--------------|--------------|-------|
| Open request | `ff9016a7aa537816...` | `ff9016a7aa537816...` | YES |
| Echo request (seq 0) | `d9874f82b3131031...` | `d9874f82b3131031...` | YES |
| Echo reply (seq 0) | `eb30c07d6375a30b...` | `eb30c07d6375a30b...` | YES |
| Close request | `f5cd0fa9de9d7d66...` | `f5cd0fa9de9d7d66...` | YES |
Also verified the spec's test vectors (18.1 and 18.2) — both match when
using the correct 92-byte packet size.
**Conclusion:** HMAC-MD5 is computed over the entire packet buffer with the
16-byte HMAC field zeroed. This is a **normative requirement**.
**Additional finding:** The test vectors in Section 18 are described as
"target length 256 bytes (shown truncated)" but the HMACs were actually
computed over 92-byte packets (76-byte header + 16-byte payload). The
"256 bytes" description is incorrect in the spec.
---
### 19.7 Server Received Window Validity — RESOLVED
**Question:** Is bit 0 always set for valid windows, making 0 a sentinel for
invalid?
**Observed window values from `captures/full-session.pcapng`:**
| Seq | Received Count | Received Window (hex LE) | Window (binary, low bits) |
|-----|---------------|-------------------------|--------------------------|
| 0 | 1 | `01 00 00 00 00 00 00 00` | ...0001 |
| 1 | 2 | `03 00 00 00 00 00 00 00` | ...0011 |
| 2 | 3 | `07 00 00 00 00 00 00 00` | ...0111 |
**Analysis:** Bit 0 (LSB) represents the current packet, which was
obviously received (it's being replied to). This bit is always 1 for
valid windows. A window value of 0 would indicate either an invalid window
or a bug.
**Conclusion:** Bit 0 is always set for valid windows. A window value of 0
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
**Question:** What happens when HMAC keys don't match?
**Commands:**
```
irtt server -b 127.0.0.1:2120 --hmac=correctkey
irtt client --hmac=wrongkey --timeouts=500ms 127.0.0.1:2120
irtt client --timeouts=500ms 127.0.0.1:2120 # no HMAC
```
**Observed behavior:**
| Scenario | Server Log | Client Result |
|----------|-----------|--------------|
| Wrong HMAC key | `[Drop] [BadHMAC] invalid HMAC: ...` | `[OpenTimeout] no reply from server` (exit 1) |
| No HMAC to HMAC server | `[Drop] [NoHMAC] no HMAC present` | `[OpenTimeout] no reply from server` (exit 1) |
**Conclusion:** The server silently drops packets with missing or invalid
HMAC. The client times out. This is **observed behavior** — the protocol does
not define an explicit HMAC error response.
---
### 19.9 Minimum Restricted Interval Safety Floor — PARTIALLY RESOLVED
**Question:** What safety floor does the client enforce for server-restricted
intervals?
**Test setup:** Server with -i 500ms (minimum interval), client requests
200ms.
**Command:**
```
irtt server -b 127.0.0.1:2123 -i 500ms
irtt client -d 2s -i 200ms 127.0.0.1:2123
```
**Observed:** `[ServerRestriction] server increased interval from 200ms to
500ms` — client exits with code 1 in strict mode. In loose mode, client
accepts 500ms interval.
**Limitation:** This test only confirms that the client rejects server-
increased intervals in strict mode. It does not confirm the exact safety
floor value for server-decreased intervals (which would require a server
that decreases interval below the client's request). The safety floor for
decreased intervals cannot be easily tested without a cooperative server
that sends deliberately small intervals.
**Conclusion:** Strict mode rejects any server parameter change by default.
The specific safety floor for decreased intervals remains an **open
question** that requires further testing.
---
### 19.10 Maximum Parameter Buffer Size — PARTIALLY RESOLVED
**Question:** Is there a maximum serialized parameter payload size?
**Test setup:** Open request with all parameters including a 24-character
ServerFill string.
**Observed:** Largest serialized parameter payload was 52 bytes (with
ProtocolVersion, Duration, Interval, Length=1472, ReceivedStats=both,
StampAt=both, Clock=both, DSCP=0xb8, ServerFill="pattern:aabbccddeeff0011").
**Analysis:** Even with maximum-value parameters and the longest reasonable
ServerFill string (32 bytes max), the parameter buffer stays well under
128 bytes. A rough upper bound:
- Tag+value overhead per param: ~6 bytes for large values
- 8 params × 6 bytes = 48 bytes
- Plus ServerFill: tag(1) + length(1) + string(32) = 34 bytes
- Total: ~82 bytes maximum
**Conclusion:** The 128-byte limit stated in the spec appears safe.
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
**Question:** Is the field ordering in Section 8.1.3 correct?
**Test setup:** Multiple captures with different field combinations.
**Verified orderings:**
1. **No optional fields (stats=none, tstamp=none):**
Token → Seq. Packet = 16 bytes. ✓
2. **Count + send wall:**
Token → Seq → Recv Count → Send Wall. Packet = 28 bytes. ✓
3. **Window + receive mono:**
Token → Seq → Recv Window → Recv Mono. Packet = 32 bytes. ✓
4. **Both + midpoint both:**
Token → Seq → Recv Count → Recv Window → Midpoint Wall → Midpoint Mono.
Packet = 44 bytes. ✓
5. **Both + both both (default):**
Token → Seq → Recv Count → Recv Window → Recv Wall → Recv Mono →
Send Wall → Send Mono. Packet = 60 bytes. ✓
6. **HMAC + both + both both:**
HMAC → Token → Seq → Recv Count → Recv Window → Recv Wall → Recv Mono →
Send Wall → Send Mono. Packet = 76 bytes. ✓
**Conclusion:** The field ordering in Section 8.1.3 is correct. This is a
**normative requirement** — incorrect ordering breaks interoperability.
---
### 19.3 Server Close During Test — NOT DIRECTLY TESTED (superseded)
**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) 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:** 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.
---
### 19.12 RTT When Server Processing > Raw Round-Trip — NOT DIRECTLY TESTED
**Limitation:** This edge case requires server processing time to exceed
the raw client-measured RTT, which is extremely unlikely on localhost. Would
require a heavily loaded server or deliberate delays. Remains an
**open question**.
---
## Additional Findings
### Finding A: Packet Count Formula Correction
**Spec claim:** Expected packet count = `floor(d / i) + 1`
**Test setup:** Multiple sessions with various duration/interval combinations.
**Command:** `irtt client -d <D> -i <I> 127.0.0.1 -q`
**Results:**
| Duration | Interval | Observed Packets | floor(d/i)+1 | ceil(d/i) |
|----------|----------|-----------------|-------------|-----------|
| 10s | 200ms | 50 | 51 | 50 |
| 1s | 200ms | 5 | 6 | 5 |
| 1s | 333ms | 4 | 4 | 4 |
| 1s | 500ms | 2 | 3 | 2 |
| 1s | 1s | 1 | 2 | 1 |
| 2s | 1s | 2 | 3 | 2 |
| 500ms | 100ms | 5 | 6 | 5 |
| 100ms | 10ms | 10 | 11 | 10 |
| 999ms | 1s | 1 | 1 | 1 |
| 1001ms | 1s | 2 | 2 | 2 |
**Analysis:** When duration is an exact multiple of interval, the packet at
exactly `start + duration` is NOT sent (exclusive end). The correct formula
is:
```
expected_packets = ceil(duration / interval)
```
Or equivalently:
```
if duration % interval == 0:
expected_packets = duration / interval
else:
expected_packets = floor(duration / interval) + 1
```
**Conclusion:** The spec's formula `floor(d / i) + 1` is incorrect. The
correct formula is `ceil(d / i)`. The spec MUST be updated.
---
### Finding B: DSCP Parameter Encodes TOS Byte Value
**Spec claim:** DSCP valid values are "0-63 (6-bit DSCP field)"
**Test setup:** Captured packets with various --dscp values.
**Results:**
| CLI Option | TOS Byte in IP Header | DSCP (6-bit) | Param Value Encoded |
|------------|----------------------|-------------|-------------------|
| --dscp=0xb8 | 0xb8 | 46 (EF) | 184 |
| --dscp=0x2e | 0x2e | 11 | 46 |
| --dscp=46 | 0x2e | 11 | 46 |
| --dscp=8 | 0x08 | 2 | 8 |
| --dscp=0x20 | 0x20 | 8 (CS1) | 32 |
**Conclusion:** The DSCP parameter encodes the raw value as specified on the
CLI, which is used directly as the TOS/Traffic Class byte in the IP header.
The range is 0-255, not 0-63. The spec MUST be updated to reflect that
the parameter is the TOS byte value, not the 6-bit DSCP field.
---
### Finding C: DSCP Not Applied to Open/Close Packets
**Observation:** In all captures, the IP TOS field for open request, open
reply, and close request packets is always 0x00, regardless of the
negotiated DSCP value. Only echo request and echo reply packets carry the
negotiated DSCP/TOS value.
**Verified with:** `captures/dscp-session.pcapng`
**Conclusion:** This is **observed behavior**. It may not be a strict
protocol requirement, but implementations should be aware that DSCP is only
applied during the active test phase.
---
### Finding D: No-Test Mode Packet Exchange
**Test setup:** Captured a no-test session.
**Command:** `irtt client -n 127.0.0.1:2113`
**Capture:** `captures/no-test.pcapng`
**Observed:**
Open request (frame 1):
```
14 a7 5b 05 [params...]
```
- Flags: `05` = Open (0x01) | Close (0x04)
- No connection token
Open reply (frame 2):
```
14 a7 5b 07 00 00 00 00 00 00 00 00 [params...]
```
- Flags: `07` = Open (0x01) | Reply (0x02) | Close (0x04)
- Connection token: `00 00 00 00 00 00 00 00` (zero)
**Conclusion:** In no-test mode:
- Client sets both Open and Close flags in the open request.
- Server replies with Open|Reply|Close and a **zero** connection token.
- The client treats this as successful completion, not rejection.
- The Close flag in the open reply is **not** a rejection when the client
originally requested Close.
The spec's description of "An open reply with the close flag set: the
server rejected the session" needs clarification. The Close flag in the
reply indicates rejection ONLY when the client did not request Close in its
open request.
---
### Finding E: Test Vector Size Correction
The test vectors in Section 18 are described as "target length 256 bytes
(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.
---
### Finding F: Default Server Fill Pattern
**Observation:** When no server fill is requested, the server fills reply
payloads with the repeating hex pattern `69 72 74 74` (ASCII "irtt"). This
is the default server fill as documented in server help:
`--fill=fill payload fill if not requested (default pattern:69727474)`.
---
### Finding G: Close Packet Minimal Format
**Observed close packet:**
```
14 a7 5b 04 [8-byte connection token]
```
Total: 12 bytes. Contains only magic (3) + flags (1) + connection token (8).
No sequence number, no payload.
With HMAC:
```
14 a7 5b 0c [16-byte HMAC] [8-byte connection token]
```
Total: 28 bytes.
---
### Finding H: Unreachable Server Behavior
**Command:** `irtt client --timeouts=200ms,200ms 127.0.0.1:2121` (no server)
**Observed:** `Error: read udp4 ... connection refused` (exit 1)
On localhost, the OS delivers ICMP port unreachable immediately. On remote
hosts, the behavior would be a timeout after all retransmissions are
exhausted.
---
### Finding I: Parameter Restriction Behavior
**Commands:**
```
irtt server -b 127.0.0.1 -l 100 # max length 100
irtt client -l 200 127.0.0.1 # request length 200
```
**Strict mode (default):** `[ServerRestriction] server reduced length from
200 to 100` — exit code 1.
**Loose mode (--loose):** `[ServerRestriction] server reduced length from
200 to 100` — continues with length 100, exit code 0.
---
## 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 |
| `captures/basic-session.pcapng` | Basic session (partial, missed open) |
| `captures/no-test.pcapng` | No-test mode: open+close only |
| `captures/hmac-session.pcapng` | HMAC authenticated session |
| `captures/tstamp-none.pcapng` | Multiple sessions with different timestamp modes |
| `captures/dscp-session.pcapng` | DSCP=0xb8 session |
| `captures/dscp-46.pcapng` | DSCP=46 and DSCP=0x2e sessions |
| `captures/dscp-values.pcapng` | DSCP=8 and DSCP=0x20 sessions |
| `captures/large-packet.pcapng` | 1472-byte packet session |
| `captures/long-sfill.pcapng` | Session with long server fill string |
## Test Vectors Created
See `test-vectors/` directory.
## Summary
### Resolved Open Questions
- **19.1** Open request field layout (no token)
- **19.2** Minimum packet length calculation
- **19.4** Varint encoding compatibility
- **19.5** HMAC computation scope
- **19.7** Received window validity (bit 0 sentinel)
- **19.8** HMAC mismatch behavior
- **19.11** Field ordering verification
### Partially Resolved
- **19.9** Minimum restricted interval safety floor (strict mode rejects,
but exact floor for decreased intervals untested)
- **19.10** Maximum parameter buffer size (observed well under 128 bytes)
### Still Unresolved
- **19.3** Server close during test (hard to trigger)
- **19.6** Send timestamp capture timing (implementation detail, not testable)
- **19.12** RTT when server processing exceeds raw RTT (hard to trigger)
### Spec Corrections Applied
1. Packet count formula: `ceil(d/i)` not `floor(d/i)+1`
2. DSCP parameter: TOS byte value (0-255) not 6-bit DSCP (0-63)
3. Test vector size: 92 bytes not 256 bytes
4. No-test mode close flag: clarified in Sections 6.2, 8.3, and 14
5. Server close during test (19.3): reclassified as open/verification
required; removed source-derived claims; spec now recommends
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).