docs(protocol): add sanitized server compatibility research #67

Merged
Lochnair merged 2 commits from docs/server-protocol-airlock into main 2026-08-11 11:34:52 +00:00
22 changed files with 4251 additions and 185 deletions

No files matched your search

+28 -9
View File
@@ -15,13 +15,19 @@ Before editing, also check for a more specific `AGENTS.md` in the target subtree
Compatibility and interoperability references: Compatibility and interoperability references:
- `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` — verified wire/measurement - `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` — verified client-side
compatibility baseline. compatibility baseline.
- `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md` — black-box observations - `docs/protocol/IRTT_SERVER_PROTOCOL_SPEC.md` — verified server-side
resolving ambiguous behavior in the original specification. compatibility baseline.
- `docs/protocol/test-vectors/README.md` — captured packet vectors. - `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md` — black-box evidence and
corrections supporting both the client and server baselines.
- `docs/protocol/test-vectors/README.md` — captured client-facing packet
vectors.
- `docs/protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md` — black-box
server behavioral vectors, edge cases, and session behavior.
- `docs/protocol/captures/` — packet captures referenced by the verification - `docs/protocol/captures/` — packet captures referenced by the verification
report. report and behavioral vectors. Behavioral vectors and captures are
evidence/artifacts, not architecture prescriptions.
- `docs/INTEROP_COMPARISON.md` — procedures for black-box comparison against - `docs/INTEROP_COMPARISON.md` — procedures for black-box comparison against
the upstream `irtt` executable. the upstream `irtt` executable.
- `docs/clean-room/CLEANROOM_NOTES.md` — provenance only; not a normative - `docs/clean-room/CLEANROOM_NOTES.md` — provenance only; not a normative
@@ -33,9 +39,9 @@ For compatibility questions, the verified spec and black-box observations define
## Architecture ## Architecture
Main boundary: Main boundary (`irtt-server` is planned, not yet implemented):
> `irtt-proto` implements wire semantics. `irtt-client` implements client/session behavior. `irtt-stats` aggregates events. `irtt-cli` presents and orchestrates them. > `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation. - `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events. - `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
@@ -43,12 +49,25 @@ Main boundary:
- `AsyncClient`: optional Tokio low-level adapter. - `AsyncClient`: optional Tokio low-level adapter.
- `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface. - `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface.
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime. - `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
- `irtt-server`: planned; see "Server architecture (`irtt-server`)" below.
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic. - `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here. - `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
A first-class reusable `irtt-server` crate is a valid project direction. It must follow the same clean-room boundary and share wire logic through `irtt-proto`. A future real server may replace compliant fake peers in integration tests; purpose-built adversarial peers remain appropriate for intentionally malformed or non-compliant behavior. ### Server architecture (`irtt-server`)
`irtt-server` is a planned first-class reusable server crate. It does not exist yet; this section describes the intended boundary for it, not an implemented API.
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
- Unlike `irtt-client`, the server has no product requirement to remain runtime-free: `irtt-server` is intentionally **Tokio-native**, and Tokio does not need to be optional for it.
- There is currently no requirement for a blocking server API, alternate async runtimes, a runtime abstraction layer, or a runtime-free server feature. Do not build blocking/runtime variants merely for symmetry with the client, and do not invent a mirrored client-style API family (`BlockingServer`, `AsyncServer`, `ManagedServer`, `BlockingManagedServer`, or similar) ahead of agreement.
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
- The CLI server applet, when added, should remain thin orchestration/configuration over reusable `irtt-server`, not contain a second server state machine.
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds (see `IRTT_SERVER_PROTOCOL_SPEC.md`) is not an interoperability requirement and must not be reproduced merely for compatibility.
- Testing: once a real `irtt-server` exists, prefer it for normal compliant fake-peer behavior where practical. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
## Engineering rules ## Engineering rules
+10 -1
View File
@@ -2,13 +2,22 @@
Repository-wide guidance in the root `AGENTS.md` also applies here. Repository-wide guidance in the root `AGENTS.md` also applies here.
`irtt-proto` is the pure protocol boundary. Keep it free of sockets, runtimes, client/server lifecycle, statistics, and presentation concerns. `irtt-proto` is the pure protocol boundary. Keep it free of sockets, runtimes, client/server lifecycle, session tables, statistics, and presentation concerns.
As `irtt-server` is built, it may need server-facing wire-inspection
primitives here, such as request decoding, reply encoding, packet/header
validation, HMAC-presence inspection, and token/header inspection before
Params-dependent decoding.
For protocol behavior, consult before changing semantics: For protocol behavior, consult before changing semantics:
- `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` - `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md`
- `docs/protocol/IRTT_SERVER_PROTOCOL_SPEC.md`
- `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md` - `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md`
- `docs/protocol/test-vectors/README.md` - `docs/protocol/test-vectors/README.md`
- `docs/protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md`
Protocol changes that affect server-side parsing, encoding, or session-facing wire semantics must consult the server spec and behavioral vectors as well as the client-side material above.
Focused unit and table-driven tests are explicitly encouraged in this crate. Exact wire vectors, layout rules, flag combinations, malformed inputs, boundary values, HMAC behavior, and encode/decode round trips are durable compatibility coverage rather than test-count inflation. Focused unit and table-driven tests are explicitly encouraged in this crate. Exact wire vectors, layout rules, flag combinations, malformed inputs, boundary values, HMAC behavior, and encode/decode round trips are durable compatibility coverage rather than test-count inflation.
+516 -93
View File
@@ -2,10 +2,41 @@
## Purpose ## Purpose
This document records the clean-room boundary for the IRTT client This document records the clean-room boundary for the IRTT reimplementation
reimplementation project. The goal is to ensure that the implementation project. The goal is to ensure that the implementation is developed from a
is developed from a behavioral/protocol specification only, without exposure behavioral/protocol specification only, without exposure to the GPL-licensed
to the GPL-licensed upstream source code. upstream source code.
It covers three passes and a final sanitization:
- the **client** pass (2026-04-28), producing
`../protocol/IRTT_CLIENT_PROTOCOL_SPEC.md`;
- the **server** pass (2026-08-11), producing
`../protocol/IRTT_SERVER_PROTOCOL_SPEC.md` and its behavioral vectors, and
applying corrections back to the client specification;
- the **midpoint timestamp follow-up** (2026-08-11);
- the **final sanitization pass** (2026-08-11), recorded at the end of this file.
## How to Read This File
This is a provenance record. It states *what class of material was excluded* and
*what discipline was applied*; it deliberately does **not** restate the excluded
material. A note that says "an upstream identifier was removed" and then quotes
the identifier has removed nothing. Earlier revisions of this file made exactly
that mistake, and the audit sections below have been rewritten to describe
categories rather than instances.
Two things that are *not* contamination, recorded here so they are not
re-flagged:
- **Parameter names.** ProtocolVersion, Duration, Interval, Length,
ReceivedStats, StampAt, Clock, DSCP and ServerFill appear throughout the
specifications. They come from upstream's **published** documentation of its
client's machine-readable output, not from source inspection.
- **Quoted diagnostic text.** Where a document quotes a message printed by the
upstream client or server, that is transcribed program output observed during
testing — the same thing any black-box tester sees — and is cited as evidence
of observed behavior.
## Clean-Room Boundary ## Clean-Room Boundary
@@ -13,23 +44,21 @@ to the GPL-licensed upstream source code.
upstream source code, documentation, tests, CLI behavior, and release upstream source code, documentation, tests, CLI behavior, and release
notes to understand the IRTT protocol. notes to understand the IRTT protocol.
- **Clean side:** The implementation agent will receive only the - **Clean side:** The implementation agent will receive only the documents
`IRTT_CLIENT_PROTOCOL_SPEC.md` document. The implementation agent MUST NOT under `docs/protocol/` and this notes file. The implementation agent MUST NOT
see the upstream source code. see the upstream source code.
## Source Materials Inspected ## Source Materials Inspected
The following materials from the upstream IRTT repository (heistp/irtt, The following materials from the upstream IRTT project (heistp/irtt, GPLv2) were
GPLv2) were inspected to produce the protocol specification: inspected to produce the protocol specification:
1. **Documentation files:** 1. **Published end-user documentation:** the project overview and FAQ, the
- `README.md` — project overview, FAQ, features, limitations general and per-command manual pages, and the changelog. This material is
- `doc/irtt.md` — main man page public and may be relied on directly; it is cited in the specifications where
- `doc/irtt-client.md` — client man page with CLI options and JSON output format used.
- `doc/irtt-server.md` — server man page (referenced, not fully read)
- `CHANGES.md` — changelog
2. **Source files (inspected for protocol behavior only):** 2. **Source (inspected for protocol behavior only):**
- Protocol version and constants - Protocol version and constants
- Packet format, field layout, magic bytes, flags - Packet format, field layout, magic bytes, flags
- Parameter serialization format (tag-value pairs, varint encoding) - Parameter serialization format (tag-value pairs, varint encoding)
@@ -64,66 +93,60 @@ copied.
## Post-Drafting Audit ## Post-Drafting Audit
After the initial specification was drafted, a clean-room audit was After the initial specification was drafted, a clean-room audit was performed.
performed. The following issues were identified and corrected: The categories of problem found and corrected were:
1. **Upstream private type names leaked:** "ctoken" and "seqno" appeared 1. **Upstream private abbreviations in the terminology table.** Removed and
as parenthetical abbreviations in the terminology table. Removed. replaced with descriptive names.
2. **Upstream implementation preference disclosed:** A note about IPv4 2. **An upstream implementation preference disclosed** in the address-resolution
preference in dual-stack resolution revealed an upstream implementation discussion. Removed; the spec now leaves the choice to the implementer.
choice. Removed.
3. **Go-specific function references:** References to `binary.PutUvarint`, 3. **Implementation-language standard-library references** in the parameter
`binary.PutVarint`, and `encoding/binary` package named the upstream encoding section. Replaced with language-neutral descriptions (LEB128,
language's standard library. Replaced with language-neutral descriptions protobuf-style zigzag).
(LEB128, protobuf-style zigzag).
4. **Internal scheduling algorithm described:** The send scheduling section 4. **An internal scheduling algorithm described** in the send-scheduling section,
described a specific "snap-to-interval" drift-correction algorithm that mirroring upstream control flow. Replaced with observable behavior: packets
mirrored upstream control flow. Replaced with observable behavior are sent at approximately their ideal times and drift compensation is
description (packets sent at approximately ideal times, drift implementation-defined.
compensation is implementation-defined).
5. **Upstream timer algorithm named:** "The upstream uses exponential 5. **An upstream timer-smoothing strategy named.** Removed.
averaging of timer errors" revealed an internal implementation choice.
Removed.
6. **RTT edge case speculated:** The spec asserted that server processing 6. **An RTT edge-case guard asserted** that upstream does not in fact implement.
time exceeding raw RTT should cause subtraction to be skipped — a guard Moved to Open Questions.
the upstream does not actually implement. Moved to Open Questions.
7. **Received window validity asserted normatively:** The behavior of 7. **Received-window validity asserted normatively** when it was
window=0 as an invalidity sentinel was stated as fact when it should be verification-required. Softened with a reference to Open Questions.
verification-required. Softened with reference to Open Questions.
8. **Go concurrency term leaked:** "goroutine" appeared in the result model 8. **An implementation-language concurrency term** in the result-model section.
section. Replaced with "operation." Replaced with a neutral word.
9. **Upstream test design referenced:** Section 18.2 described what "the 9. **Upstream test internals referenced** in the test-vector section. Rewritten
upstream test" was testing internally. Rewritten to describe the test to describe the vector neutrally.
vector neutrally.
10. **Upstream constants asserted normatively:** The minimum restricted 10. **Internal constants asserted as protocol requirements** — a minimum
interval (1 second) and maximum parameter buffer size (128 bytes) are restricted interval and a maximum parameter buffer size. Moved to Open
internal constants that were stated as protocol requirements. Moved to Questions with verification suggestions. Both were subsequently settled by
Open Questions with verification suggestions. measurement (Sections 19.9 and 19.10 of the client specification).
11. **Timestamp capture order stated as MUST:** Implementation-level timing 11. **Timestamp capture order stated as MUST** when it was implementation-level
advice was stated as a protocol requirement. Changed to SHOULD with timing advice. Downgraded, and later rewritten again in the final
measurement-accuracy rationale. sanitization pass to describe only the externally relevant relationship.
12. **Internal recording structure described:** Section 19.6 described a 12. **An internal recording structure described** in the send-timing discussion.
"pre-send timestamp" used for send-call-time measurement, which is an Rewritten to focus on observable behavior.
internal recording detail. Rewritten to focus on observable behavior.
13. **Self-contradictory correction note:** Section 8.2 contained an inline 13. **A self-contradictory inline correction note** that revealed the drafting
"Correction:" note that was confusing and revealed the drafting process. process. Cleaned up.
Cleaned up.
14. **Field ordering lacked verification note:** The critical field order 14. **Field ordering stated without a verification caveat** despite being
in Section 8.1.3 was derived from source and stated without any source-derived at the time. Verification note added, and the ordering was
verification caveat. Added verification note and new Open Question 19.11. subsequently confirmed by capture across six field combinations
(Section 19.11).
No instance of the removed material is reproduced here. Restating a private
identifier in order to record that it was removed would defeat the removal.
## Clean-Room Compliance Checklist ## Clean-Room Compliance Checklist
@@ -145,45 +168,445 @@ performed. The following issues were identified and corrected:
## Second-Pass Clean-Room Scrub ## Second-Pass Clean-Room Scrub
A second independent review was performed on 2026-04-28 by an agent that A second review was performed on 2026-04-28 by an agent that had not seen the
had not seen the upstream source. The following issues were identified and upstream source. The categories of problem found and corrected were:
corrected:
1. **Implementation architecture directive in scope:** Section 2 stated 1. **An implementation-architecture directive in the scope section**, which
"Library-first implementation target," which prescribes internal prescribed internal structure. Removed; the specification now constrains only
architecture. Removed; the spec now says only that CLI design is not observable behavior.
prescribed.
2. **Concurrency architecture leaked:** Section 6.3 described "a separate 2. **A concurrency architecture prescribed** in the active-test section,
receive path processes incoming reply packets concurrently," which implying an internal threading or task model. Rewritten to state the
prescribes an internal threading/task model. Rewritten to state the observable requirement — sending and receiving proceed concurrently — without
observable requirement (send and receive occur concurrently) without
prescribing how. prescribing how.
3. **Source-derived design rationale in echo request:** Section 8.4 3. **Source-derived design rationale** offered for the zeroed placeholder fields
explained that zeroed fields exist to "use the same field layout that in an echo request. Simplified to the observable purpose: reaching the
appears in echo replies," which reveals internal design reasoning. negotiated packet length.
Simplified to state only the observable purpose (reaching the
negotiated packet length).
4. **Source-exhaustive knowledge parenthetical:** Section 8.6 said 4. **A parenthetical revealing source-exhaustive knowledge** of the complete
"(currently only ServerFill)" for string-typed values, revealing parameter set. Removed.
knowledge of the complete parameter set from source inspection.
Removed the parenthetical.
5. **Upstream CLI syntax leaked:** Section 10.10 used `NxD`/`NrD`/`D` 5. **Upstream CLI option syntax mirrored** in the wait-variant notation.
notation for wait variants, which mirrors upstream CLI option syntax.
Replaced with plain-language descriptions. Replaced with plain-language descriptions.
6. **Algorithm name disclosed:** Section 12.9 named "Welford's online 6. **A named statistics algorithm recommended.** Replaced with a statement that
algorithm" as a MAY recommendation. This is implementation guidance. the method of computing running statistics is implementation-defined.
Replaced with "the method of computing running statistics is
implementation-defined."
7. **Implementation-specific error category:** Section 14 listed 7. **An implementation-specific error category** that implied a particular
"Allocate results buffer failure," implying a pre-allocated buffer allocation strategy. Generalized.
strategy. Generalized to "Insufficient resources for test parameters."
8. **Language-specific references in CLEANROOM_NOTES.md:** The Purpose 8. **Implementation-language references in this notes file itself.** Replaced
section named "the Rust implementation" and the checklist mentioned with language-neutral phrasing.
"No Rust API design." Both replaced with language-neutral phrasing.
As above, the removed instances are described by category and not reproduced.
---
## Server Pass (2026-08-11)
### Scope
A second contaminated-side pass characterised the upstream **server** and
produced `../protocol/IRTT_SERVER_PROTOCOL_SPEC.md`, together with
`../protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md` and four new captures.
Corrections and clarifications arising from it were applied to the client
specification.
### Upstream Material Inspected
Three upstream builds are involved in this work, and the sanitized output names
whichever one it means everywhere it matters:
| Build | Role |
|-------|------|
| **0.9.1 release** | the behavioral baseline; every unqualified statement refers to it |
| **0.9.0 release** | comparison only, for the version difference in the midpoint follow-up |
| **development tree, six upstream commits past the 0.9.1 tag** | comparison only; post-release work, **not** part of 0.9.1, and never allowed to redefine the baseline |
*Provenance correction (final sanitization pass):* an earlier revision described
that development tree as "eight commits past the 0.9.1 tag". The 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**, and the outgoing documents now say so consistently.
Inspection covered, at a high level:
1. **Published documentation:** the server manual page, the changelog, and the
project overview's security, FAQ and roadmap sections.
2. **Source (for behavior only):** request admission and validation, session
creation and lookup, parameter restriction, echo handling, reception
statistics, timestamp selection, payload filling, close handling, session
expiry, and server configuration defaults.
3. **Release history:** tags and commits between 0.9.1 and the checked-out
development head, to classify version differences.
Source inspection was used only to decide **what to test**. Every normative
statement in the sanitized output rests on an observed experiment, a capture, or
published end-user documentation — not on the source.
### Method, and What Each Part of It Establishes
The distinction below is load-bearing and is preserved in the outgoing documents.
- **Black-box observation — a raw UDP harness.** It drove a real upstream server
directly, so that requests no conforming client would emit could be tested
(out-of-order sequence numbers, truncated packets, invalid flag combinations,
foreign-endpoint packets, corrupted authentication, and so on). It shares no
code with any implementation. Its measurements are stated as observed wire
behavior.
- **Black-box observation — the real upstream client.** Driving the unmodified
upstream client and observing what it recorded, reported and exited with is an
observation of a genuinely independent second implementation.
- **Measurement, not constant-reading.** Timing boundaries (idle-expiry grace,
maximum-duration grace, interval caps) were established by measuring the
server's responses, not by reading values out of the source.
- **Contaminated-side consistency validation — a server written on this side.**
A server was built from the behavioral model and driven by the real upstream
client across eleven configurations. Because it was written on the contaminated
side it is **not** an independent implementation, and nothing in the outgoing
documents rests on it. What it establishes is that the specification as written
is sufficient to build something the upstream client accepts; the client's
acceptance is the black-box part. Earlier revisions called this an "independent
server"; that description was inaccurate and has been corrected throughout.
- All research scripts, logs, probe sources and notes were kept outside `docs/`
and are not part of the outgoing material.
### Statement of Non-Copying (Server Pass)
No upstream source code, comments, pseudocode, tests, or file/module names were
copied into any document under `docs/`. The sanitized output describes
externally observable behavior only.
Where upstream behavior differs from what a naive reading of the wire format
would suggest — the received-window reset on reordering, the unenforced protocol
version, the midpoint dual-field emission — that difference is documented as an
**observation**, with the input that produces it and the output it yields. No
explanation of *why* the implementation behaves that way appears in the
sanitized output, because any such explanation would necessarily be
source-derived.
*Amended 2026-08-11:* this remains true of the received-window and
protocol-version items. For the dual-field midpoint emission, one narrowly scoped
**source-assisted historical conclusion** was admitted and labelled — a judgement
that the measured 0.9.0-to-0.9.1 difference was unintended. See "Midpoint
Timestamp Follow-Up" below.
Two upstream robustness defects are recorded in the server specification
(`../protocol/IRTT_SERVER_PROTOCOL_SPEC.md` Section 22) because both are
reachable from ordinary client traffic and a clean implementation has to decide
what to do instead. They are stated as observed outcomes plus **robustness
recommendations for the clean implementation** — explicitly not as
interoperability requirements, since no conforming client can observe which
choice a server made — with no description of the internal cause.
### Server-Pass Sanitization Audit
The following were checked for and are absent from all files under `docs/`:
1. Upstream private function, type, field and variable names.
2. Upstream source file names, module names and package names.
3. Implementation-language identifiers, standard-library references and
concurrency vocabulary.
4. Copied or paraphrased upstream comments.
5. Source-shaped pseudocode and control-flow transcription.
6. Internal constants that are not externally observable. Timing values that
*are* observable (the idle-expiry grace, the maximum-duration grace, the
interval cap ratio) are stated with the measurement that establishes them and
are labelled as server policy, not protocol law.
7. Internal data-structure descriptions. Session lookup is described purely in
terms of which packets are accepted and which are dropped.
8. Token generation strategy. The specification states only the uniqueness
requirement and explicitly leaves generation to the implementer.
The following items were considered and deliberately **excluded** from the
sanitized output as implementation-internal:
- how session state is stored, indexed, or reclaimed;
- how packet buffers are managed or reused;
- the mechanism by which expired sessions are noticed;
- any description of why the received-window behavior takes the form it does.
### Clean-Room Compliance Checklist (Server Pass)
- [x] No upstream source code included.
- [x] No upstream comments quoted.
- [x] No upstream private function or type names included.
- [x] No upstream file/module layout described.
- [x] No implementation-language references remain.
- [x] No pseudocode derived from upstream implementation.
- [x] No internal data structures or algorithms described.
- [x] Observable behavior separated from server policy throughout.
- [x] Every normative statement backed by an experiment, capture, or public
documentation.
- [x] Version differences classified explicitly, with each build named (server
specification Section 21).
- [x] Unresolved questions recorded rather than inferred (server
specification Section 23).
- [x] Contaminated-side consistency validation performed against the real
upstream client, and labelled as such rather than as independent
verification.
- [x] Contaminated research notes, scripts and probe sources kept outside
`docs/`.
---
## Midpoint Timestamp Follow-Up (2026-08-11)
### Scope
A focused contaminated-side investigation of one behavior: the timestamp layout
upstream 0.9.1 emits for StampAt = Midpoint. It produced changes to the server
specification (Sections 9.2, 11.3.1, 21.1, 21.2, 23.4, 24), the client
specification (Sections 8.5, 19.14), the verification report (Part III,
Finding S-18, plus a caveat on Section 19.2) and the behavioral vectors
(Section 5.2 and its new subsections).
### A Different Provenance Mix From the Earlier Passes
The earlier passes used source inspection **only to decide what to test**. This
pass went further: contaminated-side analysis was used to form expectations,
which were then tested. The sanitized output therefore contains one class of
statement the earlier passes did not, and it is labelled wherever it appears.
What crosses the boundary falls into four kinds:
- **Black-box observed** — the wire order (wall field then monotonic field), the
affected StampAt/Clock combinations, the dependence of the reply length on the
negotiated length, the value an ordinary positional decoder reads in the
monotonic-only case, and the 0.9.0 / 0.9.1 / tested-post-0.9.1 comparison. All
measured with a raw UDP harness against three separate server builds, and
cross-checked at the application layer with the real upstream client.
- **Source-assisted historical conclusion** — one judgement only: that the
measured difference between 0.9.0 and 0.9.1 is an unintended regression rather
than a designed feature. It is labelled where it appears, it is not normative,
and the measured version difference is stated separately and stands on its own.
- **Interoperability requirement** — narrowly, what a clean client must tolerate
for this one verified case.
- **Robustness / implementation recommendation** — what a clean server should
emit.
The specification wording keeps these four apart explicitly, so a clean-side
reader can tell which statements they may rely on as observed fact.
Note in particular what is **not** in the list. An earlier revision also carried
a characterisation of how upstream conceptually represents a midpoint value.
That was an internal model, it was source-derived, and the useful wire behavior
is fully established without it, so it was removed from all implementation-facing
documentation in the final sanitization pass.
### What Was Deliberately Not Transferred
Detailed upstream implementation structure, internal representation, control
flow, change-level source evidence, and the source-derived reasoning supporting
the regression diagnosis were retained only on the contaminated side.
That sentence is the whole of the disclosure, and deliberately so. An earlier
revision of this file enumerated the withheld findings in enough detail that
reading the list conveyed much of what the list claimed to be withholding. Naming
the categories is what a provenance record needs; restating the contents defeats
the exclusion it is recording.
What crossed the boundary from that body of work is one sentence of conclusion —
labelled source-assisted, non-normative, and separable from every requirement in
the outgoing documents.
### Method
- A raw UDP harness performed the open negotiation, sent a single echo request,
and reported the exact reply datagram length and the bytes at each timestamp
offset. It shares no code with any implementation.
- Three server builds were exercised, each named wherever its results are used:
the **0.9.1 release**, the **0.9.0 release**, and a build of the upstream
development tree **six upstream commits past the 0.9.1 tag**. Builds and
harness live outside this tree.
- The real upstream client was used as a second, genuinely independent observer
for the application-layer effects.
### Clean-Room Compliance Checklist (Midpoint Follow-Up)
- [x] No upstream source code, comments or pseudocode included.
- [x] No upstream private function, type or field names included.
- [x] No upstream control flow described.
- [x] No upstream file or module layout described.
- [x] No internal conceptual model of upstream's timestamp representation
included.
- [x] The single source-assisted statement labelled as such, confined to a
historical judgement, and never presented as black-box verified.
- [x] Black-box observation, source-assisted conclusion, interoperability
requirement and robustness recommendation kept distinct.
- [x] Excluded contaminated-side material described by category only, without
restating its content.
- [x] Earlier over-general claim ("always 8 bytes longer") corrected rather than
left standing.
---
## Final Sanitization Pass (2026-08-11)
A last audit of the entire outgoing `docs/` tree before it crosses the airlock.
No new protocol research was performed; this pass only corrected, reclassified
and removed.
### Provenance
- Every statement now names the build it came from. The **0.9.1 release** is the
baseline; the **0.9.0 release** and the **development tree six upstream commits
past the 0.9.1 tag** appear only where explicitly labelled, and neither is
allowed to stand in for 0.9.1.
- The "eight commits past the 0.9.1 tag" figure was corrected to **six**. The
earlier count wrongly included two commits belonging to this clean-room project
itself.
- The server written on this side is now described everywhere as **contaminated-
side consistency validation**, never as an independent implementation. Only the
raw UDP harness and the real upstream client are treated as black-box
observers.
### Statement classification
The outgoing documents now separate five kinds of statement and never conflate
them: interoperability requirement; black-box observed upstream behavior;
upstream policy or default; robustness recommendation for the clean
implementation; and source-assisted historical conclusion.
Specific reclassifications made in this pass:
- Surviving a failed reply send is a **robustness recommendation**, not a
protocol MUST. No conforming client can observe which choice a server made.
- Upstream's absence of session and per-peer bounds is **observed policy**, and
the documents now say explicitly that it is not something a compatible server
should reproduce. The clean server is expected to be bounded; the shape of that
policy is left to the clean project.
- The fill-validation, DSCP-clamping and length-clamping items in the hazards
section were likewise moved from "requirement" to robustness recommendation.
- The server specification's conformance summary is split: interoperability
MUST / MUST NOT lists, and a separate list of robustness recommendations.
### Architecture neutrality
The outgoing documents prescribe no runtime or architecture for the clean server
— no blocking-versus-async choice, no runtime-free API, no core-type shape, no
socket ownership arrangement. Those are clean-project decisions and belong in the
clean repository after the airlock, not in protocol documentation. The
architecture-neutrality statement in the server specification's scope section was
extended to say so directly.
### Wording removed or rewritten
- **Source-derived timestamp placement.** Statements locating a timestamp
immediately before or after a particular system call were replaced with the
externally relevant property: the pair must bracket the server's handling of
the request, receive never later than send. Where an implementation takes its
readings is not observable and is no longer described.
- **Validation-order narrative.** The packet-admission section was rewritten from
a numbered processing sequence into a table of discard conditions plus the two
ordering relationships that were actually verified externally (Open-versus-Close
and Open-versus-echo-shaped-body interpretation precedence for an otherwise
admissible datagram, which overrides no independent rejection condition; and the
indistinguishability of an authentication failure from an unknown token).
Behavioral wording replaced control-flow wording throughout.
- **Internal midpoint model.** The characterisation of upstream's internal
representation of a midpoint timestamp was removed from all
implementation-facing documentation. What remains is the wire fact: a
dual-field midpoint representation, wall field followed by monotonic field.
- **DSCP.** The wire parameter is now stated plainly as the raw IP TOS /
Traffic-Class byte, range 0–255, with Expedited Forwarding at 0xb8 / 184. The
future user-facing notation for the clean project is marked out of scope rather
than baked into the protocol documents.
### Corrected claims
- **Midpoint reply length.** Every "negotiated length + 8" and "normal packet
length + 8" claim was removed. The documents now state the model uniformly:
`upstream_header` is `normal_header` 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)`; and the
observable excess is +8, then +7…+1, then 0 as the negotiated length rises. The
measured sweeps demonstrating this were preserved.
- **Overlong replies.** The blanket requirement that a client tolerate any reply
longer than the negotiated length was narrowed to the one verified case:
single-clock midpoint at **exactly** `upstream_0_9_1_reply_len`. That is one
additional accepted length per negotiation, not a `+1..+8` window — where
`upstream_0_9_1_reply_len > compatible_reply_len` the two values differ by
between 1 and 8 bytes, but a length strictly between them is rejected like any
other unexpected length. The documents say explicitly that this does not
generalise to arbitrary overlong echo replies and that strict validation of
other malformed or oversized packets is retained.
- **Normal minimum reply size.** Reply-length validation is stated against
`compatible_reply_len`, the normal compatible reply length, rather than against
the negotiated `Length` value alone; the two differ whenever the negotiated
Length is below the mandatory field block.
- **Monotonic-only midpoint.** The suggestion that a client may simply read the
second 8-byte region as the monotonic value was replaced with a conservative
rule. In the equal-length regime the conforming and upstream forms are
indistinguishable, so the second region may be ordinary payload; a correction
is permitted only where the dual-field form is otherwise identifiable, the
ambiguity is recorded rather than resolved by heuristic, and the packet must be
accepted either way.
- **0.9.0 → 0.9.1.** Any claim of no protocol-visible change between the two was
corrected. Published release-note content and measured wire behavior are now
stated separately, and they disagree on this point.
- **Protocol version.** Stale text using "protocol version mismatch" as an
example of a normal server rejection was removed. Upstream 0.9.1 accepts
version 0, 2, negative and absent, and returns 1; enforcement is client-side.
- **Received window.** The documents now state that `0x1` means no useful prior
received-history is represented, **not** that the previous 63 packets were
lost. Downstream/upstream loss classification is flagged as a separate question
scheduled for its own clean-side audit.
- **Close semantics.** No production standalone close-reply codec is specified.
Such a datagram was observed to be tolerated by upstream clients but has no
demonstrated purpose in protocol version 1, and the documents say so.
- **Parameter payload size.** The "believed to be 128 bytes" limit was replaced
with the measured result: no protocol limit on a received payload; 128 bytes is
a local allocation choice.
### Contamination scan
The whole outgoing `docs/` tree was scanned against a private-token list derived
on the contaminated side from the upstream tree. That list is **not** reproduced
here, and neither is any token from it.
Scanned for and now absent: upstream private function, type, field and variable
identifiers; implementation-language identifiers and standard-library references;
upstream source filenames, module and package names; source-derived commit
identifiers; local filesystem paths; scratchpad paths; and temporary build paths.
The residual hits found and fixed in this pass were all in the audit prose of
this file, which had been quoting the identifiers it claimed to have removed, and
one tooling-environment block in the verification report that carried a local
installation path and an implementation-language version string.
Retained deliberately, and not contamination: the public upstream project
identity (`heistp/irtt`) where provenance requires it; the parameter names, which
come from upstream's published documentation; and quoted upstream program output,
which is observed black-box evidence.
### Captures
Every `.pcapng` intended to cross was metadata-reviewed. Four had already been
container-normalized during the server pass; the remaining ten still carried
capture-machine hardware and operating-system identification, the local capture
interface name and description, and the capture filter string. All ten were
regenerated to strip that metadata.
No capture contained a hostname, a username, a filesystem path, a command string
or any reference to an upstream checkout.
**Packet bytes were not altered.** For each regenerated file the full frame
hexdump and the per-frame epoch timestamps were compared before and after and
were identical. No capture had to be excluded from the outgoing set.
### Outgoing tree hygiene
The outgoing `docs/` tree contains only specifications, the verification report,
behavioral and packet vectors, and captures. No research scripts, probe sources,
upstream source archives, build outputs, temporary logs, dirty source notes or
private-identifier lists are present, and the contaminated-side material for the
midpoint work was left where it already lives, outside `docs/`.
One incidental local file sits inside `docs/` and is **not** outgoing material: a
local tool-permissions file under a dotted directory. It is ignored by version
control, it is not tracked, and it may not be copied across the airlock. The
macOS Finder metadata file previously listed here is no longer present; the
exclusion still stands, since Finder can recreate one at any time. Re-verified
2026-08-11: the dotted directory is the only ignored entry under `docs/`, and no
Finder metadata file exists anywhere in the outgoing tree.
+595 -22
View File
@@ -10,23 +10,22 @@
## Environment Check ## Environment Check
``` Tooling used for Part I. Installation paths are omitted deliberately; only the
$ command -v irtt versions matter for reproducing these results.
/Users/lochnair/.local/bin/irtt
```
$ irtt version $ irtt version
irtt version: 0.9.1 irtt version: 0.9.1
protocol version: 1 protocol version: 1
json format version: 1 json format version: 1
go version: go1.26.2 on darwin/arm64
$ command -v tshark
/opt/homebrew/bin/tshark
$ tshark --version $ 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 ## Open Question Resolution
@@ -126,6 +125,11 @@ Timestamp field count depends on StampAt and Clock:
This is a **normative requirement** — implementations must compute the This is a **normative requirement** — implementations must compute the
correct minimum to avoid malformed packets. 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 ### 19.4 Varint Encoding — RESOLVED
@@ -154,7 +158,8 @@ verified against known values.
All decoded correctly using standard protobuf-style zigzag + LEB128. 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 **Conclusion:** The varint encoding is standard protobuf-compatible. This
is a **normative requirement** for interoperability. 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) **Question:** Is the HMAC computed over the entire packet (including payload)
with the HMAC field zeroed? with the HMAC field zeroed?
**Test setup:** Captured HMAC session, extracted packet bytes, independently **Test setup:** Captured HMAC session, extracted packet bytes, and recomputed
computed HMAC-MD5 with Python. HMAC-MD5 with a separately written Python script.
**Commands:** **Commands:**
``` ```
@@ -228,6 +233,11 @@ or a bug.
can be used as an invalidity sentinel. This is a **compatibility can be used as an invalidity sentinel. This is a **compatibility
recommendation** — implementations SHOULD treat window=0 as invalid. 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 ### 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 Reclassified as **compatibility recommendation** — implementations should
allocate at least 128 bytes for the parameter buffer. 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 ### 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 **Limitation at the time:** Triggering a server-initiated close mid-test requires
specific server conditions that are difficult to set up reliably in a 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 black-box test. The spec's description (server MAY set Close flag on echo
reply) has not been verified externally. Reclassified as **open / reply) had not been verified externally, and was reclassified as **open /
verification required** — the spec now recommends defensive handling verification required**.
without asserting that this behavior occurs.
**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 ### 19.6 Send Timestamp Capture Timing — NOT TESTABLE
**Limitation:** The exact timing of client-side timestamp capture relative **Limitation:** How closely a client's own timestamp capture sits to its send
to the send syscall is an implementation-internal detail that cannot be and receive operations is internal to that client and cannot be verified through
verified through black-box testing. Remains an implementation choice. 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 ### Finding E: Test Vector Size Correction
The test vectors in Section 18 are described as "target length 256 bytes The test vectors in Section 18 are described as "target length 256 bytes
(shown truncated)." Independent HMAC verification confirms that both test (shown truncated)." Recomputing the HMAC from the captured bytes confirms that
vectors (18.1 and 18.2) were computed over 92-byte packets (76-byte header 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 + 16-byte payload), not 256-byte packets. The "256 bytes" description is
incorrect. incorrect.
@@ -560,6 +579,12 @@ irtt client -l 200 127.0.0.1 # request length 200
## Captures Created ## 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 | | File | Contents |
|------|----------| |------|----------|
| `captures/full-session.pcapng` | Complete session: open, 3 echoes, close | | `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 defensive handling without asserting the behavior occurs
6. Received window late-packet behavior: removed unverified claim about 6. Received window late-packet behavior: removed unverified claim about
late packets producing window=0; retained verified bit-0 sentinel 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).
+370 -58
View File
@@ -1,8 +1,23 @@
# IRTT-Compatible Client Protocol Specification # IRTT-Compatible Client Protocol Specification
**Version:** 1.1-verified - **Version:** 1.3-verified
**Date:** 2026-04-28 - **Date:** 2026-08-11
**Verified against:** irtt 0.9.1, protocol version 1, macOS Darwin 25.3.0 arm64 - **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 ## 2. Scope
- **Client-only implementation.** Server implementation is out of scope unless - **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. - **Compatible with existing IRTT servers** running protocol version 1.
- This specification describes protocol and measurement behavior; CLI design - This specification describes protocol and measurement behavior; CLI design
is not prescribed. 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. extracted from the reply payload. The client stores the connection token.
Transitions to Active Test (or Completed if the no-test flag was set). 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 - 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 Close in its request**: the server rejected the session. Transition to
version mismatch). Transition to Failed. 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 - 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. in its request** (no-test mode): the server acknowledged the open+close.
The connection token is zero. Transition to Completed. 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: The client MUST reject (as an error) the following server parameter changes:
- Duration increased beyond what was requested. - Duration increased beyond what was requested.
- Length 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 check:** The client MUST verify that the server's
protocol version matches exactly. Protocol version 1 is the current protocol version matches exactly. Protocol version 1 is the current
version. Mismatch MUST cause the session to fail. 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 ### 6.3 Active Test
- **Entry:** Open reply received and parameters accepted. - **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 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 | | 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 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 - Received Window (8 bytes) — if negotiated, contains 64-bit received bitmap
- Timestamp fields — according to negotiated stamp_at and clock settings - 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 ### 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. Unknown parameter tags MUST be silently ignored by the receiver.
The maximum serialized parameter buffer size is believed to be 128 bytes There is **no protocol limit** on the size of a received parameter payload; a
(see Section 19.10 for verification). 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:** **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 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. 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 ### 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 - **Effect:** Sets the TOS/Traffic Class byte in the IP header for both
client and server packets during the active test phase. client and server packets during the active test phase.
- **Default:** 0 (best effort). - **Default:** 0 (best effort).
- **Valid values:** 0-255 (full TOS byte value). The DSCP field occupies - **Wire encoding (protocol fact):** the DSCP parameter carries the **raw IP TOS
the upper 6 bits; the lower 2 bits (ECN) are typically zero. / Traffic Class byte**, range **0–255** — not a 6-bit codepoint. The
Common values: 0 (best effort), 0xb8 (EF), 0xa0 (CS5), 0x20 (CS1). differentiated-services codepoint occupies the upper 6 bits of that byte and
[**Verified 2026-04-28** — see BLACKBOX_VERIFICATION_REPORT.md Finding B.] 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. - **Protocol:** The server echoes packets with the negotiated DSCP value.
The server MAY disallow DSCP and reset it to 0. The server MAY disallow DSCP and reset it to 0.
- **Observed behavior:** DSCP/TOS is applied only to echo request and echo - **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 - **Client receive timestamp:** SHOULD be captured as close as possible to
the receive system call return, before any packet processing. the receive system call return, before any packet processing.
- **Server timestamps:** Determined by the negotiated StampAt parameter: - **Server timestamps:** Determined by the negotiated StampAt parameter:
- Receive: captured when the server receives the packet. - Receive: the server's arrival instant for the request.
- Send: captured just before the server sends the reply. - Send: the server's departure instant for the reply.
- Midpoint: the average of receive and send times. - 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 ### 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 - A set bit means the server received that packet; a clear bit means it
did not. did not.
The window may not be valid for all replies. Bit 0 (LSB) is always set Bit 0 (LSB) is always set in a window emitted by a server, since it represents
for valid windows, since it represents the current packet which was the current packet which was received. [**Verified 2026-04-28** — see
received. [**Verified 2026-04-28** — see Section 19.7.] Implementations Section 19.7.] Implementations SHOULD treat a window value of 0 as potentially
SHOULD treat a window value of 0 as potentially invalid and avoid using invalid and avoid using it for loss classification. Note that upstream 0.9.1
it for loss classification. 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: 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 - If a packet was not received by the client but the window says the server
received it → lost downstream. received it → lost downstream.
- If a packet was not received by the client and the window says the server - If a packet was not received by the client and the window says the server
did not receive it → lost upstream. did not receive it → lost upstream.
- If the window cannot confirm (e.g., more than 64 packets ago, or window - If the window cannot confirm (e.g., more than 64 packets ago, or the window
was invalid) → generic loss. carries no history) → generic loss.
The window has a maximum lookback of 63 packets (bits 1-63). 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 ### 12.9 Statistics
For RTT, send delay, receive delay, and IPDV, a conforming client SHOULD 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 ### 13.7 Close Flag in Echo Reply
If the server sets the Close flag in an echo reply, the client SHOULD A server MAY forcibly end a session mid-test by setting the Close flag (0x04) on
process the reply normally (record measurements) and then close the an otherwise complete echo reply. This is the only in-band mechanism for a
connection. This is defensive handling for the possibility that the server-initiated close; there is no standalone server close packet.
server can forcibly end a session mid-test.
**Note:** This behavior has not been directly verified by black-box A client MUST handle this case. On receiving an echo reply with the Close flag
testing (see Section 19.3). Implementations SHOULD handle it defensively. 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. | | Send error during test | SHOULD abort, report partial results. |
| Receive error during test | SHOULD abort, report partial results. | | Receive error during test | SHOULD abort, report partial results. |
| Individual malformed packet during test | SHOULD abort. | | 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. | | Stamp-at mismatch in reply | SHOULD abort. |
| Clock mismatch in reply | SHOULD abort. | | Clock mismatch in reply | SHOULD abort. |
| Unexpected sequence number | 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 complete table. The minimum is 16 bytes (no stats, no timestamps) and the
maximum default is 76 bytes (both+both+both+HMAC). 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 **Result:** Yes. A server that enforces a maximum test duration answers the
(e.g., due to exceeding a duration limit)? If so, how should the client first echo request arriving after its hard deadline (the configured maximum
respond? 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 A conforming client never reaches this deadline, because it stops sending at the
flag in echo replies and, if set, process the reply normally (record negotiated duration. The mechanism exists to bound sessions from clients that
measurements) and then terminate the session. This is defensive handling ignore the negotiation.
for a scenario that has not been directly observed in black-box testing.
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 ### 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. **Status:** Verified 2026-04-28, irtt 0.9.1, macOS arm64.
**Result:** HMAC-MD5 is computed over the entire packet buffer (including **Result:** HMAC-MD5 is computed over the entire packet buffer (including
payload) with the 16-byte HMAC field zeroed. Independently verified with payload) with the 16-byte HMAC field zeroed. Cross-checked with a separately
Python for all four packet types (open request, echo request, echo reply, 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 close request). The spec's test vectors (18.1 and 18.2) also verify
correctly at 92-byte packet size. 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 value of 0 can be used as the invalid sentinel. See
BLACKBOX_VERIFICATION_REPORT.md Section 19.7. 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 ### 19.8 Open Reply Without HMAC When Client Expects HMAC — ✅ RESOLVED
**Status:** Verified 2026-04-28, irtt 0.9.1, macOS arm64. **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 behavior for wrong key and no key. See BLACKBOX_VERIFICATION_REPORT.md
Section 19.8. 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 **Result:** The safety floor is **1 second**, and it is enforced in loose mode
with a non-zero exit code. In loose mode, changes are accepted with a as well as strict mode.
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.
### 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 A reduction to exactly 1 second (requesting 2 s against a 4 s idle timeout) is
including 24-character ServerFill). Upper bound estimate is ~82 bytes. The treated as an ordinary server restriction: rejected in strict mode, accepted in
128-byte limit appears safe. See BLACKBOX_VERIFICATION_REPORT.md loose mode.
Section 19.10.
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 ### 19.11 Field Ordering Verification — ✅ RESOLVED
@@ -1429,6 +1650,97 @@ negative value?
This edge case requires server processing time to exceed the raw This edge case requires server processing time to exceed the raw
client-measured RTT, which is extremely unlikely under normal conditions. 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 ## 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.
+2 -2
View File
@@ -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 4-19: HMAC `ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36`
- Bytes 20-39: Parameter payload - Bytes 20-39: Parameter payload
**HMAC verified independently with Python (HMAC-MD5 over entire packet **HMAC cross-checked with a separately written Python script (HMAC-MD5 over the
with HMAC field zeroed).** 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.