From ff550c41ef4147cb2ae2e23a90e07f0d8c3bf071 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sun, 12 Jul 2026 02:09:47 +0900 Subject: [PATCH] feat(nano): federating hasJoined multiplexer with per-source UUID namespacing --- docs/openapi.yaml | 35 +++++ go.mod | 2 +- internal/api/api.go | 14 ++ internal/api/handlers_hasjoined.go | 143 ++++++++++++++++++++ internal/api/handlers_hasjoined_test.go | 169 ++++++++++++++++++++++++ 5 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers_hasjoined.go create mode 100644 internal/api/handlers_hasjoined_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 16eef2a..9d6c373 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -439,6 +439,41 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /session/minecraft/hasJoined: + get: + tags: [nano] + operationId: hasJoined + summary: Multi-source session verifier (Felis-nano hasJoined multiplexer). + description: >- + Velocity's authlib is pointed here via -Dmojang.sessionserver or a thin login + hook. Unauthenticated — the vanilla sessionserver protocol carries no token. The + query is fanned out to the configured Yggdrasil roots in priority order (the + Mojang identity source first); the first source to validate the serverId hash + wins. A non-identity source's self-asserted UUID is rewritten into a per-source + namespace (UUIDv3) before return, so it can never land in Mojang's UUID space. + A rejected or barred login is 204, which authlib maps to a verify failure. + x-felis-face: [internal] + x-felis-tier: public + security: [] + parameters: + - { name: username, in: query, required: true, schema: { type: string } } + - { name: serverId, in: query, required: true, schema: { type: string } } + - { name: ip, in: query, required: false, schema: { type: string } } + responses: + '200': + description: A source validated the session; the canonical game profile. + content: + application/json: + schema: + type: object + required: [id, name] + properties: + id: { type: string, description: Canonical UUID, undashed 32-hex. } + name: { type: string } + properties: { type: array, items: { type: object } } + '204': + description: No source validated the session, or the resolved UUID is barred. + # -------------------------------------------------- internal: servers ------ /api/v1/servers: get: diff --git a/go.mod b/go.mod index 75c39bb..0ce519f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/descope/virtualwebauthn v1.0.5 github.com/go-webauthn/webauthn v0.17.4 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.1 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 @@ -55,7 +56,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/internal/api/api.go b/internal/api/api.go index fb98c2b..b6743e0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -115,6 +115,13 @@ type API struct { // positive value. Enforced via streamGate in the two relay handlers. MaxStreamsPerPrincipal int + // AuthSources is the Felis-nano multi-source hasJoined multiplexer's upstream + // Yggdrasil list, in priority order (config order; the Mojang Identity source + // first for 正版优先). Nil — the default — makes the session verifier reject every + // login (204), so the endpoint ships inert until cmd/felis wires configured + // sources. Consumed by handleHasJoined (handlers_hasjoined.go). + AuthSources []AuthSource + // Now is the clock, injectable for tests. Defaults to time.Now. Now func() time.Time @@ -260,6 +267,13 @@ func (a *API) internalAPIRoutes() []apiRoute { // Mojang player (same name, different UUID) always passes. {Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername}, {Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist}, + // Felis-nano multi-source session verifier (spec §B3 player game-login). + // Velocity's authlib is pointed here (-Dmojang.sessionserver or a thin login + // hook); it speaks the vanilla sessionserver protocol and carries no token, so + // this is Public. It fans hasJoined out to the configured Yggdrasil roots + // (Mojang-first) and rewrites third-party UUIDs into a per-source namespace + // before returning the canonical profile (handlers_hasjoined.go). + {Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined}, // Op-login (passwordless console login): an in-game op requests a login that // the web owner/admin approves, then redeems for a session. Internal face // carries the pending queue and the approve action (service-token auth, no diff --git a/internal/api/handlers_hasjoined.go b/internal/api/handlers_hasjoined.go new file mode 100644 index 0000000..430c4d6 --- /dev/null +++ b/internal/api/handlers_hasjoined.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/url" + "time" + + "github.com/google/uuid" +) + +// Felis-nano: the multi-source hasJoined multiplexer (spec §B3 player game-login). +// +// Velocity's session verifier (authlib) is pointed here — via -Dmojang.sessionserver +// on Felis-managed proxies, or a thin login-pipeline hook on third-party servers. On +// login Velocity computes the serverId hash and GETs hasJoined; this endpoint fans that +// query out to the configured Yggdrasil roots in priority order (Mojang first, 正版优先) +// and returns 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: a non-identity (third-party) source's UUID is +// self-asserted, so its profile is rewritten into a per-source namespace +// (canonical = UUIDv3(felisAuthNS, tag+":"+nativeID)) BEFORE it leaves the resolver. +// Mojang stays identity. This makes the reclaim invariant — "the genuine Mojang player +// has a DIFFERENT UUID from any squatter" — true by construction, not assumed: MD5 +// preimage resistance means no third-party source can mint a Mojang-space UUID, and the +// per-tag namespace means two sources cannot collide onto one identity. Every downstream +// key (account_links, username_blacklist, owner checks) then sees one canonical UUID. + +// felisAuthNS is the fixed UUIDv3 namespace every third-party profile is rewritten +// under (see the rewrite rationale above). Derived from the project name, not a magic +// literal, so its origin is self-documenting; the exact value only has to be stable. +var felisAuthNS = uuid.NewSHA1(uuid.NameSpaceURL, []byte("nano.felis.lolicon.best/auth-source")) + +// authHTTPClient calls the upstream Yggdrasil roots. The timeout bounds one login +// against a hung source; the resolver moves on to the next source on any failure. +// ponytail: one shared client, sequential priority scan — a third-party login costs one +// wasted Mojang round-trip; add parallel fan-out only if login latency bites. +var authHTTPClient = &http.Client{Timeout: 5 * time.Second} + +// AuthSource is one upstream Yggdrasil root in the multiplexer's priority list (config +// order = priority). URL is the full hasJoined endpoint the query string is appended to. +// Identity marks the authoritative source (Mojang) whose UUIDs are trusted as-is; every +// other source is rewritten into felisAuthNS. +type AuthSource struct { + Tag string + URL string + Identity bool +} + +// sessionProfile is the Mojang hasJoined contract. properties is relayed verbatim +// (json.RawMessage) so a source's signed textures survive the multiplexer untouched. +type sessionProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Properties []json.RawMessage `json:"properties,omitempty"` +} + +// handleHasJoined is the multi-source session verifier (Felis-nano). It is a Public +// internal-face route: authlib speaks the vanilla sessionserver protocol and sends no +// service token. A rejected login is 204 No Content — exactly what Mojang returns for an +// invalid session, which authlib maps to "failed to verify username". +func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + username, serverID := q.Get("username"), q.Get("serverId") + if username == "" || serverID == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + prof, src := a.resolveHasJoined(r.Context(), username, serverID, q.Get("ip")) + if prof == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + // Canonicalize identity. A trusted (Mojang) source keeps its UUID; a self-asserted + // source is rewritten into felisAuthNS so it can never land in Mojang's UUID space + // nor onto another source's. An unparseable identity UUID is not trustworthy → reject. + var canonical uuid.UUID + if src.Identity { + id, err := uuid.Parse(prof.ID) + if err != nil { + w.WriteHeader(http.StatusNoContent) + return + } + canonical = id + } else { + canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID)) + } + + // Bar gate at the single chokepoint every login crosses, so a reclaimed squatter + // stays out even on a consumer with no limbo plugin. Keyed on the dashed canonical + // UUID — the same form Repo.ReclaimUsername stores. + barred, err := a.Repo.IsUsernameBlacklisted(r.Context(), canonical.String()) + if err != nil { + writeError(w, r, err) + return + } + if barred { + w.WriteHeader(http.StatusNoContent) + return + } + + // Emit the canonical UUID undashed — the 32-hex form authlib's GameProfile expects. + prof.ID = hex.EncodeToString(canonical[:]) + writeJSON(w, http.StatusOK, prof) +} + +// resolveHasJoined queries each configured source in priority order and returns the +// first that validates the session (200 with a profile). A source that is down, answers +// non-200 (204 = "not my player"), or returns garbage is skipped. +func (a *API) resolveHasJoined(ctx context.Context, username, serverID, ip string) (*sessionProfile, AuthSource) { + for _, src := range a.AuthSources { + u := src.URL + "?username=" + url.QueryEscape(username) + "&serverId=" + url.QueryEscape(serverID) + if ip != "" { + u += "&ip=" + url.QueryEscape(ip) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + continue + } + resp, err := authHTTPClient.Do(req) + if err != nil { + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + continue + } + var prof sessionProfile + err = json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&prof) + resp.Body.Close() + if err != nil || prof.ID == "" { + continue + } + return &prof, src + } + return nil, AuthSource{} +} diff --git a/internal/api/handlers_hasjoined_test.go b/internal/api/handlers_hasjoined_test.go new file mode 100644 index 0000000..dd671ed --- /dev/null +++ b/internal/api/handlers_hasjoined_test.go @@ -0,0 +1,169 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" +) + +// fakeYgg stands in for one upstream Yggdrasil root. It answers hasJoined with the +// given profile, or 204 when id == "" ("not my player") or a query field is missing — +// the same contract Mojang's real sessionserver honors. +func fakeYgg(t *testing.T, id, name string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if id == "" || q.Get("username") == "" || q.Get("serverId") == "" { + w.WriteHeader(http.StatusNoContent) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, "name": name, + "properties": []map[string]string{{"name": "textures", "value": "SKIN", "signature": "SIG"}}, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +func getHasJoined(h http.Handler, username, serverID string) *httptest.ResponseRecorder { + return do(h, "GET", "/session/minecraft/hasJoined?username="+username+"&serverId="+serverID, "", nil) +} + +func profileOf(t *testing.T, w *httptest.ResponseRecorder) sessionProfile { + t.Helper() + var p sessionProfile + if err := json.Unmarshal(w.Body.Bytes(), &p); err != nil { + t.Fatalf("profile body not JSON: %v (%q)", err, w.Body.String()) + } + return p +} + +// undashed is the 32-hex form the resolver must emit (authlib's GameProfile format). +func undashed(u uuid.UUID) string { return hex.EncodeToString(u[:]) } + +const notchMojangID = "069a79f444e94726a5befca90e38aaf5" // a real Mojang-space UUID, undashed + +func TestHasJoined(t *testing.T) { + // A trusted (Mojang) source passes its UUID through byte-for-byte. + t.Run("mojang identity passthrough", func(t *testing.T) { + mojang := fakeYgg(t, notchMojangID, "Notch") + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "mojang", URL: mojang.URL, Identity: true}} + + w := getHasJoined(api.InternalHandler(), "Notch", "abc") + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%q)", w.Code, w.Body.String()) + } + p := profileOf(t, w) + if p.ID != notchMojangID { + t.Fatalf("mojang id = %q, want unchanged %q", p.ID, notchMojangID) + } + if len(p.Properties) != 1 { + t.Fatalf("properties not relayed: %v", p.Properties) + } + }) + + // THE security invariant: a self-asserted source claiming a Mojang-space UUID must + // NOT be emitted as-is — it is rewritten into the per-source namespace. Without this, + // a malicious third-party could impersonate any Mojang player with full UUID fidelity + // and the reclaim/blacklist layer (keyed on "genuine Mojang has a different UUID") + // could never catch it. + t.Run("thirdparty UUID rewritten, never emitted as-is", func(t *testing.T) { + evil := fakeYgg(t, notchMojangID, "Notch") // lies: returns real Notch's Mojang UUID + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "littleskin", URL: evil.URL, Identity: false}} + + w := getHasJoined(api.InternalHandler(), "Notch", "abc") + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", w.Code) + } + got := profileOf(t, w).ID + if got == notchMojangID { + t.Fatalf("SECURITY: third-party Mojang-space UUID emitted as-is (%q) — impersonation open", got) + } + want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID))) + if got != want { + t.Fatalf("rewrite = %q, want deterministic UUIDv3 %q", got, want) + } + }) + + // Mojang is priority-first: when both would validate the same name, Mojang wins. + t.Run("mojang priority wins over thirdparty", func(t *testing.T) { + mojang := fakeYgg(t, notchMojangID, "Notch") + third := fakeYgg(t, "aaaaaaaaaaaa4aaaaaaaaaaaaaaaaaaa", "Notch") + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{ + {Tag: "mojang", URL: mojang.URL, Identity: true}, + {Tag: "littleskin", URL: third.URL, Identity: false}, + } + w := getHasJoined(api.InternalHandler(), "Notch", "abc") + if p := profileOf(t, w); p.ID != notchMojangID { + t.Fatalf("id = %q, want mojang %q (priority)", p.ID, notchMojangID) + } + }) + + // Mojang doesn't know the player (204) → fall through to the third-party source, + // whose profile is returned rewritten. + t.Run("fallthrough to thirdparty when mojang 204s", func(t *testing.T) { + mojang := fakeYgg(t, "", "") // 204: not my player + third := fakeYgg(t, notchMojangID, "Notch") + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{ + {Tag: "mojang", URL: mojang.URL, Identity: true}, + {Tag: "littleskin", URL: third.URL, Identity: false}, + } + w := getHasJoined(api.InternalHandler(), "Notch", "abc") + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", w.Code) + } + want := undashed(uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID))) + if p := profileOf(t, w); p.ID != want { + t.Fatalf("id = %q, want rewritten thirdparty %q", p.ID, want) + } + }) + + // No source validates → 204 (authlib maps this to a verify failure). + t.Run("no source validates -> 204", func(t *testing.T) { + a := fakeYgg(t, "", "") + b := fakeYgg(t, "", "") + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{ + {Tag: "mojang", URL: a.URL, Identity: true}, + {Tag: "littleskin", URL: b.URL, Identity: false}, + } + if w := getHasJoined(api.InternalHandler(), "Ghost", "abc"); w.Code != http.StatusNoContent { + t.Fatalf("code = %d, want 204", w.Code) + } + }) + + // The reused bar gate: a barred CANONICAL UUID is rejected at the resolver, so a + // reclaimed squatter stays out even on a consumer with no limbo plugin. Keyed on the + // dashed canonical (post-rewrite), the same form Repo.ReclaimUsername stores. + t.Run("barred canonical UUID -> 204", func(t *testing.T) { + third := fakeYgg(t, notchMojangID, "Notch") + repo := newFakeRepo() + canonical := uuid.NewMD5(felisAuthNS, []byte("littleskin:"+notchMojangID)) + repo.blacklist[canonical.String()] = true // barred by a prior reclaim + api := newTestAPI(repo, newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "littleskin", URL: third.URL, Identity: false}} + + if w := getHasJoined(api.InternalHandler(), "Notch", "abc"); w.Code != http.StatusNoContent { + t.Fatalf("barred login: code = %d, want 204", w.Code) + } + }) + + // Missing query fields → 204 without touching any source. + t.Run("missing username -> 204", func(t *testing.T) { + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.AuthSources = []AuthSource{{Tag: "mojang", URL: "http://127.0.0.1:0", Identity: true}} + w := do(api.InternalHandler(), "GET", "/session/minecraft/hasJoined?serverId=abc", "", nil) + if w.Code != http.StatusNoContent { + t.Fatalf("code = %d, want 204", w.Code) + } + }) +}