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:
parent
5c6a6924c1
commit
3c5b79b791
1 changed files with 106 additions and 0 deletions
106
docs/notes/token-validator-wiring-confirm.md
Normal file
106
docs/notes/token-validator-wiring-confirm.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue