docs(bf-5ig3e): confirm validator wired unconditionally + hello.Token-only check

Verify the validator-side half of the tokenless-sim REJECT root cause
against current source (first link of the bf-34lwt split):

- mothership/cmd/mothership/main.go:4494 — SetTokenValidator(...) is a
  bare statement with no env/build guard; provSrv is created
  unconditionally at :4492. 'validator configured?' is always YES.
- mothership/internal/ingestion/server.go:513 — tokenOK :=
  hello.Token != "" && validator(hello.MAC, hello.Token) depends
  solely on the deserialized hello JSON body field (message.go:22),
  not any HTTP header. No X-Spaxel-Token read exists on this path.

Conclusion: validator always configured; accept/reject hinges entirely
on hello.Token.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-07 06:38:33 -04:00
parent 5c6a6924c1
commit 3c5b79b791

View file

@ -0,0 +1,106 @@
# Confirmation: validator wired unconditionally + reads `hello.Token` only
**Tracking:** bead `bf-5ig3e` (first link of the `bf-34lwt` split).
**Date:** 2026-07-07.
**Scope:** isolate and re-verify the *validator-side* half of the
tokenless-sim REJECT root cause against current source. This confirms the two
statements "validator configured? always YES" and "acceptance/rejection hinges
entirely on `hello.Token`". The header-supply half of the root cause is covered
by the parent note [`token-reject-root-cause.md`](./token-reject-root-cause.md)
(bead `bf-34lwt`); this note re-checks the two facts that note depends on, so a
drift in either is detectable on its own.
> **Path convention.** Citations are full repo-relative paths. The `bf-5ig3e`
> task body drops the `mothership/` module prefix, so its
> `cmd/mothership/main.go:4494` is `mothership/cmd/mothership/main.go:4494`
> here, and its `internal/ingestion/server.go:513` is
> `mothership/internal/ingestion/server.go:513`.
## Fact 1 — validator is wired UNCONDITIONALLY
`mothership/cmd/mothership/main.go:4492-4499`:
```go
provSrv := provisioning.NewServer(cfg.DataDir, cfg.MDNSName, msPort, cfg.NTPServer, cfg.InstallSecret)
r.Post("/api/provision", provSrv.HandleProvision)
ingestSrv.SetTokenValidator(provSrv.ValidateToken) // :4494 — bare statement, no guard
if cfg.MigrationWindowHours > 0 { // :4495 — gates the DEADLINE, not the validator
deadline := time.Now().Add(time.Duration(cfg.MigrationWindowHours) * time.Hour)
ingestSrv.SetMigrationDeadline(deadline)
log.Printf("[INFO] Migration window open until %s (%d h)", deadline.Format(time.RFC3339), cfg.MigrationWindowHours)
}
```
The `SetTokenValidator` call at `:4494` is a top-level statement in the wiring
sequence. There is **no `if` around it** — not behind `cfg.MigrationWindowHours`,
not behind any env var, not behind a build tag. `provSrv` is itself created
unconditionally at `:4492`. The immediately following `if
cfg.MigrationWindowHours > 0` (`:4495`) gates `SetMigrationDeadline`, not
`SetTokenValidator`. Therefore `s.tokenValidator` is always non-nil at runtime:
**"validator configured?" is always YES.**
## Fact 2 — the validator reads `hello.Token` only
`mothership/internal/ingestion/server.go:506-513`:
```go
// Token validation: if a validator is configured, reject unauthenticated nodes
// unless the migration window is still open, in which case allow but mark as Unpaired.
s.mu.RLock()
validator := s.tokenValidator
deadline := s.migrationDeadline
s.mu.RUnlock()
if validator != nil {
tokenOK := hello.Token != "" && validator(hello.MAC, hello.Token) // :513
```
`tokenOK` is a function of exactly two values, both drawn from the deserialized
`hello` WebSocket JSON frame:
- `hello.Token` — the `token,omitempty` JSON field of `HelloMessage`
(`mothership/internal/ingestion/message.go:22`), populated only by
`ParseJSONMessage(msg)` from the node's first WS frame (`server.go:489`,
`hello, ok := parsed.(*HelloMessage)` at `:496`). It is **not** an HTTP
header value.
- `hello.MAC` — passed through to the validator; irrelevant to *where* the
token comes from.
The validator itself is `provSrv.ValidateToken`,
`mothership/internal/provisioning/server.go:135`:
```go
func (s *Server) ValidateToken(mac, token string) bool {
...
expected := s.deriveToken(mac)
return subtle.ConstantTimeCompare([]byte(expected), []byte(token)) == 1
}
```
Its signature takes only `(mac, token)` — there is no header, no `*http.Request`,
no `r *http.Request` in scope anywhere on this path. A grep for the header name
over the ingestion tree returns **no** reads:
```
$ grep -rn 'X-Spaxel-Token' mothership/internal/ingestion/ # (no hits)
```
The only `Header()` reference in `server.go` (`:463`) is `w.Header().Set(...)`
on an *outbound* response. The WS upgrade at `:469`
(`conn, err := s.upgrader.Upgrade(w, r, nil)`) inspects nothing on `r`.
So there is **no** path through which an HTTP header (e.g. the `X-Spaxel-Token`
header the sim does send) can flip `tokenOK` to `true`. Acceptance/rejection
hinges entirely on `hello.Token`.
## Conclusion
> The validator is always configured (`main.go:4494`, unconditional); acceptance
> vs. rejection hinges entirely on `hello.Token` (`server.go:513`), the JSON body
> field — no HTTP header is read on this path.
This isolates the validator-side half of `bf-34lwt`. Combined with the
header-side facts in the parent note (the sim supplies its token only as the
unread `X-Spaxel-Token` header, so `hello.Token` is always `""`), it follows
that `tokenOK` is always `false` for a tokenless sim node — and the node is
accepted only while the migration window is open (the default 24h mask), then
`sendReject`-ed with `invalid_token` once it closes.