diff --git a/docs/changes/2026-07-12-felis-nano-hasjoined-resolver.md b/docs/changes/2026-07-12-felis-nano-hasjoined-resolver.md new file mode 100644 index 0000000..5c18098 --- /dev/null +++ b/docs/changes/2026-07-12-felis-nano-hasjoined-resolver.md @@ -0,0 +1,110 @@ +# Felis-nano — the federating `hasJoined` multiplexer (step 1: the Go resolver) + +- **Type:** feature (new endpoint) — the verifiable "brain" of Felis-nano +- **Date:** 2026-07-12 +- **Area:** `internal/api` (`handlers_hasjoined.go` + test), `internal/api/api.go` + (route + `AuthSources` field), `docs/openapi.yaml`, `go.mod` +- **Task:** Felis-nano provides a MultiLogin-like capability — one Velocity proxy that + accepts logins verified by **several** Yggdrasil auth servers at once (Mojang + N + third-party roots), 正版优先 (Mojang-first). This change builds **step 1**: the Go + `hasJoined` multiplexer that does the federating verification. It is the only part of + the plan that produces immediate verifiable hard evidence (a unit-tested HTTP endpoint); + the two delivery shells that point Velocity at it (a JVM `-Dmojang.sessionserver` flag, + and a thin reflection-hook plugin for third-party servers) are later steps. + +## What Velocity asks for, and what this answers + +On a Minecraft login Velocity's authlib computes the `serverId` hash and issues +`GET /session/minecraft/hasJoined?username=&serverId=[&ip=]` against +whatever URL its `mojang.sessionserver` system property names. A 200 with a game profile +means "verified"; a 204 means "not verified" and authlib rejects the login. Vanilla points +this at Mojang alone. Felis-nano points it **here**, and this endpoint fans the same query +out to the configured Yggdrasil roots **in priority order**, returning the first source +that validates. Each upstream Yggdrasil runs its own `serverId`-hash check — the +multiplexer only relays, it computes no hashes. + +## The one non-negotiable transform — per-source UUID namespacing + +A third-party Yggdrasil's UUIDs are **self-asserted**: nothing stops a malicious source +from answering with a *genuine Mojang player's* UUID. If that UUID were emitted as-is, the +third-party could impersonate any Mojang player with full UUID fidelity — and the reclaim/ +blacklist layer could never catch it, because its whole invariant is "the genuine Mojang +player has a **different** UUID from any squatter." That invariant would simply be false. + +So the resolver rewrites every non-identity source's profile into a per-source namespace +**before it leaves the resolver** — the single entry point every login crosses: + +``` +canonical = UUIDv3(felisAuthNS, tag + ":" + nativeID) // third-party +canonical = the source's UUID verbatim // Mojang (Identity: true) +``` + +MD5 (UUIDv3) preimage resistance means no third-party can mint a value inside Mojang's +UUID space; the per-`tag` prefix means two sources can't collide onto one identity. Every +downstream key — `account_links`, `username_blacklist`, owner checks — then sees exactly +one canonical UUID per real identity, so the reclaim invariant is true **by construction**, +not by assumption. + +## Fail-closed details that bite if wrong + +- **Bar gate at the chokepoint.** The canonical UUID is checked against + `Repo.IsUsernameBlacklisted` *before* the profile is returned, so a reclaimed squatter + stays out even on a consumer that has no limbo plugin. Keyed on the **dashed** canonical + (`.String()`) — the exact form `Repo.ReclaimUsername` stores. A DB error there fails + closed (non-200 → authlib rejects), matching the existing `handleCheckBlacklist` pattern. +- **Emit undashed.** authlib's `GameProfile` expects the 32-hex undashed `id` + (`hex.EncodeToString(u[:])`); the DB/reclaim/blacklist keys are dashed. The resolver + **checks** on the dashed string and **emits** the undashed one. Mixing the two forms is a + silent gate miss — pinned by the tests below. +- **`properties` relayed verbatim** (`[]json.RawMessage`) so a source's signed textures + survive the multiplexer untouched. +- **Inert by default.** `AuthSources` is nil until `cmd/felis` wires configured sources, + so the endpoint 204s every login until deliberately configured — it ships off. +- **Public internal-face route.** authlib sends no service token, so the route is mounted + `Public: true` on the internal face (like `/healthz`); no third face is introduced. The + OpenAPI parity test enforces `x-felis-face: [internal]` + `x-felis-tier: public`. + +## Files + +| File | Change | +|---|---| +| `internal/api/handlers_hasjoined.go` | **new** — `handleHasJoined` + `resolveHasJoined` + `AuthSource`/`sessionProfile` types + `felisAuthNS` | +| `internal/api/handlers_hasjoined_test.go` | **new** — `TestHasJoined`, 7 subtests over `httptest` fake Yggdrasil roots | +| `internal/api/api.go` | `AuthSources []AuthSource` field (nil = inert) + `GET /session/minecraft/hasJoined` `Public` internal route | +| `docs/openapi.yaml` | `/session/minecraft/hasJoined` path — `x-felis-face: [internal]`, `x-felis-tier: public`, `security: []` | +| `go.mod` | promote `github.com/google/uuid` indirect→direct (first direct importer) | + +## Verification evidence + +Oracle: WSL Fedora-44, go1.26.4. `go build ./...` → `BUILD-OK`. Full `internal/api` +package green (`ok felis.lolicon.best/internal/api`), `go vet ./internal/api/` clean. The +full package (not a `-run` filter) was run because this change edits two shared surfaces — +the `API` struct and the `internalAPIRoutes()` table — where a route that isn't under +`/api/v1/` is exactly what a route-table-driven invariant test would trip; nothing +reddened. + +`TestHasJoined` — 7 subtests, all PASS: + +1. `mojang identity passthrough` — Mojang UUID unchanged, `properties` relayed. +2. **`thirdparty UUID rewritten, never emitted as-is`** — the security invariant: an evil + source returns real Notch's Mojang UUID; the resolver emits neither that UUID nor any + Mojang-space value, but the deterministic `UUIDv3(felisAuthNS, "littleskin:"+id)`. +3. `mojang priority wins over thirdparty` — Mojang-first ordering. +4. `fallthrough to thirdparty when mojang 204s` — priority scan continues past a 204. +5. `no source validates -> 204`. +6. `barred canonical UUID -> 204` — the reused reclaim bar gate holds at the resolver. +7. `missing username -> 204` — no source touched on a malformed query. + +`TestOpenAPIMatchesServedRoutes` PASS — the new route's served facets match its +`docs/openapi.yaml` entry in both directions. + +## Deferred (not in this change) + +- **Source configuration** (step 2): a `tag`/`type`/`url`/`priority` schema and + `cmd/felis` wiring that populates `AuthSources`. Until then the endpoint is inert. +- **Delivery shells** (step 3): Shell 1 = the `-Dmojang.sessionserver` JVM flag on + Felis-managed proxies; Shell 2 = the thin reflection-hook Velocity plugin for + third-party servers, plus a Velocity verification runbook. The user has a real + server to test Shell 2 against. +- **Name-match / textures-signature enforcement** — deliberately out of scope: identity + is `tag:nativeID`, not the name, and textures are the cosmetic bucket relayed verbatim. diff --git a/docs/changes/INDEX.md b/docs/changes/INDEX.md index 0561338..f84f5c1 100644 --- a/docs/changes/INDEX.md +++ b/docs/changes/INDEX.md @@ -23,9 +23,7 @@ not yet committed. ## Pending (built + verified, not yet committed) -| Change | Detail doc | Status | -|---|---|---| -| Adversarial input-validation audit — sink-first negative-path trace; two text→RCON guards mutation-pinned; no gap, no production code change | [adversarial-input-audit](2026-07-08-adversarial-input-audit.md) | `[uncommitted]` (GPG pinentry timeout) — intended: `docs(changes): record the adversarial input-validation audit (sink-first negative-path)` | +_None._ ## Detail docs @@ -61,6 +59,8 @@ commits are the collaborator's UI work and are not given detail docs here. | [internal-backup-endpoint](2026-07-07-internal-backup-endpoint.md) | `f2fc57c` | §B4 Sync phase 2a — internal-face backup endpoint | | [internal-api-clusterip-service](2026-07-07-internal-api-clusterip-service.md) | `2ba9948` | felis-api internal-face ClusterIP Service | | [break-glass-backup-peer](2026-07-07-break-glass-backup-peer.md) | `fc748d3` | §B4 Sync phase 2b — console backup peer | +| [adversarial-input-audit](2026-07-08-adversarial-input-audit.md) | `667c6d3` | adversarial input-validation audit — sink-first negative-path, two text→RCON guards mutation-pinned, no code change | +| [felis-nano-hasjoined-resolver](2026-07-12-felis-nano-hasjoined-resolver.md) | `ff550c4` | Felis-nano §B3 — federating hasJoined multiplexer + per-source UUID namespacing | ## Committed change ledger