fix(capability): unify control-plane version and fix TS2021 endianness - #94
Merged
Conversation
added 3 commits
August 15, 2026 22:06
noise/mod.rs wrote the 2-byte TS2021 initiation version with to_le_bytes(); the reference protocol (control/controlbase/messages.go) requires big-endian, so a conforming peer decoded a different version number than the one hamma believed it advertised and the Noise transcript could not authenticate. The wire bug was a symptom of a deeper defect: the capability version was independently restated at four call sites (noise prologue/wire version = 1, the /key endpoint = 71, MapRequest.Version = 68, RegisterRequest with no Version field at all) that had already drifted apart from each other. Introduces mitos::capability::CAPABILITY_VERSION as the one typed source every control-plane surface derives from, so a future divergent literal is either a compile error (no bare u16 to restate) or a failing consistency test, not a silent handshake failure.
forkwright
force-pushed
the
fix/64-capability-version-be
branch
from
August 16, 2026 03:06
a33b569 to
40ebd1d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finding
dictyonsent the 2-byte TS2021 initiation version little-endian against a big-endian reference protocol, and independently restated the capability version at four call sites that had already drifted apart (1, 71, 68, and one omitted field) instead of deriving all of them from one truthful value.Evidence
crates/dictyon/src/noise/mod.rs:228(pre-fix) wrote the version field withPROTOCOL_VERSION.to_le_bytes(). The reference implementation (control/controlbase/messages.goin the Tailscale Go source) writes and reads this field withbinary.BigEndian.PutUint16/binary.BigEndian.Uint16— confirmed by fetching that file directly.PROTOCOL_VERSIONwas1(crates/dictyon/src/noise/mod.rs:40, pre-fix), so hamma put01 00on the wire; a conforming peer reading that big-endian decodes256, not1.crates/dictyon/src/wire/mod.rs:47(pre-fix) independently declaredKEY_PATH: &str = "/key?v=71".crates/dictyon/src/control/mod.rs:441(pre-fix) independently declaredMapRequest { version: 68, .. }.crates/mitos/src/types/mod.rs:30-58(pre-fix)RegisterRequesthad noVersionfield at all, though the referenceRegisterRequestintailcfg.gocarries one./key?v=Nquery parameter (control/controlclient/direct.go:fmt.Sprintf("%v/key?v=%d", serverURL, tailcfg.CurrentCapabilityVersion)), the TS2021 wire version,RegisterRequest.Version, andMapRequest.Version— are one value,tailcfg.CurrentCapabilityVersion, not four independently-set numbers.crates/dictyon/tests/wire_integration.rs(pre-fix) — the mock server ("oracle") hardcoded its ownNOISE_PROLOGUE: &[u8] = b"...v1"and itsdecode_handshake_headerhelper decoded but never asserted the version bytes, so a client-side endianness regression would pass every test in the suite: both sides were wrong the same way.Why this matters
A wire-format mismatch this specific can't be caught by a round-trip test against the client's own encoder/decoder — that only proves hamma agrees with itself. Against any conforming TS2021 peer the two sides mix different bytes into the Noise handshake hash and the transcript cannot authenticate, so
dictyoncannot complete a handshake with a real control server. That is Phase A's stated acceptance condition (README.md: "Phase A — dictyon client against tailscale.com. Validates the Rust client on a production reference server").Four independent literals for one protocol value is also a structural invitation for exactly this kind of divergence to happen again the next time any one of these four call sites is touched without the others.
Desired correction
mitos::capability::CapabilityVersion/mitos::CAPABILITY_VERSION(crates/mitos/src/capability.rs) as the single typed source.to_be_bytes()is the only way to get wire bytes out of it, so there is no bareu16left for a call site to independently mis-encode.noise/mod.rsnow writesCAPABILITY_VERSION.to_be_bytes()and builds the prologue fromCAPABILITY_VERSION;wire/mod.rs'skey_path(),control/mod.rs'sMapRequest/RegisterRequestconstruction, andmitos::types::RegisterRequest's newVersionfield all derive from the same constant.crates/dictyon/src/transport.rsandcrates/dictyon/examples/connect.rshad their own hand-rolledsnowfixtures still declaring"...v1"literally — fixed inline, since a fixture whose prologue silently stops matching the real client's is a handshake failure waiting to happen, not a cosmetic string.71, not upstream's currentCurrentCapabilityVersion(well past 100): that value gates client features (node attrs, DNS extensions, ...) hamma does not implement, so advertising it would be a false capability claim.71was already the value dictyon spoke at its most-deliberately-reasoned call site (the/keyendpoint's own prior comment: "the wire version dictyon speaks") before the three other call sites had drifted away from it.mitos::capability::tests::capability_version_encodes_big_endianpinsCAPABILITY_VERSION.to_be_bytes()to the literal[0x00, 0x47]— not re-derived through the function under test.dictyon::noise::tests::handshake_initiation_produces_messageandinitiation_frame_has_correct_structurenow assert the same literal big-endian bytes onNoiseHandshake::initiation_message()'s real output (previously asserted the little-endian bug as correct behavior).wire_integration.rs's mock server now asserts the exactGET /key?v=71 HTTP/1.1request line the real client sent over TLS, anddecode_handshake_headerasserts the initiation's version bytes equalCAPABILITY_VERSION.to_be_bytes()— this is the "independently implemented oracle" the issue asked for: a rawsnow::Builderresponder, notdictyon::noise, checking the real bytes the real client put on the wire over a real TLS connection.control::tests::register_and_map_requests_advertise_the_same_capability_versionbuilds both real JSON payloads and compares them to each other, not just each to the constant — a future one-sided hardcode fails this even if it coincidentally still equals71today.CI-red fix (adversarial review round 2)
An independent review found CI genuinely red and deterministic —
clippy::doc_markdown(implied by-D warnings) failing on bare technical words in new doc comments, and flagged that because clippy stops at the first failing crate without--keep-going,dictyon(which carries most of this PR's new doc comments) had never actually been lint-checked against this diff.Three bare identifiers, found and fixed one compile-cycle at a time against the real GH Actions
gate / full-gate-buildjob (each fix confirmed against the next run's actual error, not guessed):crates/mitos/src/capability.rs:29— bareWireGuardin theCAPABILITY_VERSIONdoc comment (the originally-reported failure; run31919989643/job95098225345, error atcapability.rs:29:22).crates/dictyon/src/control/tests.rs:446— bareCAPABILITY_VERSIONin a test's doc comment, only reachable once the mitos fix above let clippy proceed into dictyon (run31921865061/job95102872229, error atcontrol/tests.rs:446:40,could not compile dictyon (lib test)).crates/dictyon/src/noise/tests.rs:439— bareMAX_FRAME_PAYLOADin a test's doc comment, same class as the previous fix, in the same target; fixed in the same round rather than waiting for a fourth cycle to confirm it separately.Verified clean beyond these three: every
//////!doc comment across the wholemitos+dictyoncrate trees (diff and pre-existing) was scanned for the same two clippydoc_markdowntriggers (a bare identifier containing_, and a bare mixed-case CamelCase word not glued to adjacent text) — no other candidate remains, and this matches the real CI runs above turning up exactly these three, one dependency-layer at a time, and nothing else in the crates that had never been reached before.Confirmed on the current head (
a33b569):gate / full-gate-build— which runsfmt,check --workspace --all-targets,clippy --workspace --all-targets -- -D warnings,nextest --workspace, anddoctestas sequential steps in one job — passes (run 31922095589, 1m36s), so clippy has now genuinely completed across the whole workspace, not just the first crate, and nextest passed too.gate / gate(the evaluator) also passes.No new check is added by this round — these are doc-comment punctuation fixes only, so no negative fixture applies.
Closes #64