fix(control): validate registration responses into an exhaustive state machine - #95
Merged
Merged
Conversation
added 4 commits
August 15, 2026 20:38
…e machine RegisterResponse modeled only AuthURL/MachineAuthorized/NodeKeyExpiry (the last of which the reference protocol never sends), and register()/ poll_registration() inferred outcome from AuthURL alone: any present URL (including "") became NeedsAuth, and !MachineAuthorized with no URL fell through to Authorized. Error was never deserialized, so a rejection with an empty AuthURL was silently accepted as the empty-URL auth flow. Add Error and NodeKeyExpired to the wire DTO; drop the nonexistent NodeKeyExpiry. classify_register_response() validates every reachable field combination into one RegisterOutcome variant (Authorized, NeedsAuth over a non-empty-validated NonEmptyUrl, RotateNodeKey, Rejected, or Contradictory(RegisterFault) for the two combinations the protocol does not allow), with Error taking precedence over NodeKeyExpired over MachineAuthorized/AuthURL. poll_registration() now runs the same classifier as register() instead of returning the raw response unvalidated.
…test matches!() with a struct-variant binding moves the bound field out of the scrutinee; reusing the scrutinee in the assert! message afterward is an E0382 partial-move. Match explicitly instead, matching the pattern already used by the sibling Rejected tests in this file.
CI (gate / full-gate-build) caught it: -D warnings promotes clippy::doc_markdown to an error, and an un-backticked "PascalCase" in a register.rs test doc comment failed the workspace clippy pass.
Restores the client.poll_registration() call the report_register_outcome refactor silently dropped from examples/connect.rs, so the NeedsAuth branch performs the interactive-auth round trip again instead of only logging the URL. Retags four WHY(#66): doc comments to plain WHY: -- TAG(#NNN) is reserved for TODO/FIXME in the closed comment-tag set.
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
RegisterOutcomewas derived from a single field — whetherAuthURLdeserializedSome— rather than from the response's full protocol semantics, andRegisterResponsenever deserialized the fields that would let it do otherwise.Evidence
crates/mitos/src/types/mod.rs:131-147(pre-fix) modeledRegisterResponsewith onlyAuthURL,MachineAuthorized, and aNodeKeyExpiry: Option<String>field the reference Tailscale protocol never sends;ErrorandNodeKeyExpired(the fields the real server does send) were absent entirely.crates/dictyon/src/control/mod.rs:323-337(pre-fix) branched only onresp.auth_url.clone(): any presentAuthURL, including"", producedNeedsAuth; any absentAuthURLproducedAuthorized, regardless ofMachineAuthorized.crates/dictyon/examples/connect.rs:137-149(pre-fix) consequently reported authorization for theAuthorized(false)case, because nothing ever readMachineAuthorizedbefore declaring success.crates/dictyon/src/control/mod.rs:348-371(pre-fix):poll_registrationreturned the rawRegisterResponsewith no classification at all — the followup path had zero validation, not even the flawed kindregister()had.Three deterministic cases were misclassified (all now covered by named tests in
crates/dictyon/src/control/register.rs):{"MachineAuthorized":true,"AuthURL":""}→ wasNeedsAuth{auth_url:""}. NowAuthorized(test:authorized_with_empty_url_is_authorized_not_needs_auth).{"Error":"invalid auth key","MachineAuthorized":false,"AuthURL":""}→ discarded the rejection, became the empty-URL auth flow. NowRejected{reason:"invalid auth key"}(test:error_rejects_even_with_empty_url_and_unauthorized).{"MachineAuthorized":false}→ wasAuthorized. NowContradictory(UnauthorizedWithoutExplanation)(test:unauthorized_with_no_signal_is_contradictory_not_authorized).Why this matters
A caller cannot safely decide whether a node joined the tailnet from the old return type: a rejected auth key and a successful pre-auth registration were indistinguishable in the cases above, and the followup-poll path (the one a real interactive-auth flow actually uses after the user visits the URL) validated nothing at all.
Desired correction
RegisterResponse(crates/mitos/src/types/mod.rs) now deserializes all four reference-protocol fields:Error,NodeKeyExpired,MachineAuthorized,AuthURL. The non-existentNodeKeyExpiryis removed.classify_register_response(crates/dictyon/src/control/register.rs:103-123) validates every reachable field combination into exactly oneRegisterOutcomevariant, with fixed precedence: a non-emptyErroralways rejects first;NodeKeyExpiredrequires rotation next; only then doMachineAuthorizedand a non-emptyAuthURLcombine intoAuthorized,NeedsAuth(NonEmptyUrl), or one of twoContradictory(RegisterFault)cases for the two combinations the protocol does not allow (AuthorizedWithPendingUrl,UnauthorizedWithoutExplanation). A present-but-emptyError/AuthURLis treated as absent, matching how the reference server marshals its zero value.NonEmptyUrl(crates/dictyon/src/control/register.rs:68-76) has no public constructor — the only way to obtain one is by passing throughclassify_register_response, soRegisterOutcome::NeedsAuthcannot represent the empty-URL case the type system.poll_registration(crates/dictyon/src/control/mod.rs) now returnsResult<RegisterOutcome, ControlError>and runs the same classifierregister()does, closing the untested/unvalidated followup path.crates/dictyon/tests/register_flow/mod.rsexercises this over a real Noise-encrypted mock-server connection:register()→NeedsAuth, thenpoll_registration()on the followup URL →Rejected, proving the rejection is not discarded on the polled path either.crates/dictyon/src/control/register.rscover every valid transition and every rejected/contradictory combination, including two explicit precedence tests (error_rejects_even_when_machine_authorized_is_true,node_key_expired_wins_over_machine_authorized).crates/mitos/tests/public_api.rsgained two tests confirmingError/NodeKeyExpiredreach the public API and that an empty{}response still parses with every field at its absent/false default.Review follow-up
An independent adversarial review returned
must_fixon three findings; all three are addressed in this PR:## Desired correctiontest count above was wrong (claimed 12, the file has 11#[test]functions) — corrected.WHY(#66):doc comments incrates/dictyon/src/control/register.rsandcrates/dictyon/tests/register_flow/mod.rsused a non-member comment-tag shape (TAG(#NNN)is reserved forTODO/FIXME) — retagged to plainWHY:.report_register_outcome(outcome)refactor incrates/dictyon/examples/connect.rsdropped theclient.poll_registration(stream, &auth_url).await?call the old inlineNeedsAuthmatch arm made, so the example no longer completed the interactive-auth round trip —register_nodenow interceptsNeedsAuthbefore delegating toreport_register_outcome, performs the poll, and reports the followup outcome, restoring the round trip.No new check is added by this follow-up (a miscount fix, a comment-tag fix, and a restored function call are not new validation), so no additional negative-fixture report applies beyond the negative-case tests already itemized above (each names the pre-fix misclassification and the test that now catches it).
Closes #66