docs(changes): record the Felis-nano hasJoined resolver and reconcile the audit ledger row
This commit is contained in:
2 files changed
+113
-3
No files matched your search
@@ -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=<name>&serverId=<hash>[&ip=<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.
|
||||||
@@ -23,9 +23,7 @@ not yet committed.
|
|||||||
|
|
||||||
## Pending (built + verified, not yet committed)
|
## Pending (built + verified, not yet committed)
|
||||||
|
|
||||||
| Change | Detail doc | Status |
|
_None._
|
||||||
|---|---|---|
|
|
||||||
| 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)` |
|
|
||||||
|
|
||||||
## Detail docs
|
## 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-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 |
|
| [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 |
|
| [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
|
## Committed change ledger
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user