Skip to content

fix(control): validate registration responses into an exhaustive state machine - #95

Merged
forkwright merged 4 commits into
mainfrom
fix/66-registration-state-machine
Aug 16, 2026
Merged

fix(control): validate registration responses into an exhaustive state machine#95
forkwright merged 4 commits into
mainfrom
fix/66-registration-state-machine

Conversation

@forkwright

@forkwright forkwright commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Finding

RegisterOutcome was derived from a single field — whether AuthURL deserialized Some — rather than from the response's full protocol semantics, and RegisterResponse never deserialized the fields that would let it do otherwise.

Evidence

  • crates/mitos/src/types/mod.rs:131-147 (pre-fix) modeled RegisterResponse with only AuthURL, MachineAuthorized, and a NodeKeyExpiry: Option<String> field the reference Tailscale protocol never sends; Error and NodeKeyExpired (the fields the real server does send) were absent entirely.
  • crates/dictyon/src/control/mod.rs:323-337 (pre-fix) branched only on resp.auth_url.clone(): any present AuthURL, including "", produced NeedsAuth; any absent AuthURL produced Authorized, regardless of MachineAuthorized.
  • crates/dictyon/examples/connect.rs:137-149 (pre-fix) consequently reported authorization for the Authorized(false) case, because nothing ever read MachineAuthorized before declaring success.
  • crates/dictyon/src/control/mod.rs:348-371 (pre-fix): poll_registration returned the raw RegisterResponse with no classification at all — the followup path had zero validation, not even the flawed kind register() had.

Three deterministic cases were misclassified (all now covered by named tests in crates/dictyon/src/control/register.rs):

  • {"MachineAuthorized":true,"AuthURL":""} → was NeedsAuth{auth_url:""}. Now Authorized (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. Now Rejected{reason:"invalid auth key"} (test: error_rejects_even_with_empty_url_and_unauthorized).
  • {"MachineAuthorized":false} → was Authorized. Now Contradictory(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-existent NodeKeyExpiry is removed.
  • classify_register_response (crates/dictyon/src/control/register.rs:103-123) validates every reachable field combination into exactly one RegisterOutcome variant, with fixed precedence: a non-empty Error always rejects first; NodeKeyExpired requires rotation next; only then do MachineAuthorized and a non-empty AuthURL combine into Authorized, NeedsAuth(NonEmptyUrl), or one of two Contradictory(RegisterFault) cases for the two combinations the protocol does not allow (AuthorizedWithPendingUrl, UnauthorizedWithoutExplanation). A present-but-empty Error/AuthURL is 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 through classify_register_response, so RegisterOutcome::NeedsAuth cannot represent the empty-URL case the type system.
  • poll_registration (crates/dictyon/src/control/mod.rs) now returns Result<RegisterOutcome, ControlError> and runs the same classifier register() does, closing the untested/unvalidated followup path. crates/dictyon/tests/register_flow/mod.rs exercises this over a real Noise-encrypted mock-server connection: register()NeedsAuth, then poll_registration() on the followup URL → Rejected, proving the rejection is not discarded on the polled path either.
  • 11 tests in crates/dictyon/src/control/register.rs cover 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.rs gained two tests confirming Error/NodeKeyExpired reach 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_fix on three findings; all three are addressed in this PR:

  • The ## Desired correction test count above was wrong (claimed 12, the file has 11 #[test] functions) — corrected.
  • WHY(#66): doc comments in crates/dictyon/src/control/register.rs and crates/dictyon/tests/register_flow/mod.rs used a non-member comment-tag shape (TAG(#NNN) is reserved for TODO/FIXME) — retagged to plain WHY:.
  • The report_register_outcome(outcome) refactor in crates/dictyon/examples/connect.rs dropped the client.poll_registration(stream, &auth_url).await? call the old inline NeedsAuth match arm made, so the example no longer completed the interactive-auth round trip — register_node now intercepts NeedsAuth before delegating to report_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

forkwright 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.
@forkwright
forkwright merged commit 35f1b1f into main Aug 16, 2026
10 checks passed
@forkwright
forkwright deleted the fix/66-registration-state-machine branch August 16, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate registration responses into an exhaustive protocol state machine

1 participant