diff --git a/multiplayer-next.md b/multiplayer-next.md index 5febe725..7561bcac 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -93,7 +93,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). inert/alerting. Pure Go credential-claim validation, binding, hashing, reconciliation, and the atomic receipt/completion/outbox SQL boundary exist; projected-token/JWT adapters, trusted-cluster verification, rating-lock - integration, and production alerting remain. + integration, and production alerting remain. A dependency-free projected JWT + adapter now verifies the compact-token signature through an injected trust + boundary and delegates exact claim/time binding to the domain policy. - [x] Complete the threat model for forgery, replay, queue/flood/bot abuse, workload/insider compromise, DDoS, supply chain and denial-of-wallet ([THREAT-MODEL.md](docs/THREAT-MODEL.md)). diff --git a/multiplayer-todo.md b/multiplayer-todo.md index f68e3815..dda795b8 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1183,7 +1183,7 @@ the local/CI/community transport, not a silent production fallback. | 8.7 `[D:7.6,8.3]` | **IN PROGRESS.** Pure Go ticket policy binds the expected App ID and verified identity, rejects expiry/replay/wrong app/malformed tickets, and consumes each ticket once | `server/domain/auth.go` covers single-use and binding invariants; real `AuthenticateUserTicket` backend adapter, bans, publisher secret store and Steam verification remain | | 8.8 `[D:8.7]` | **IN PROGRESS.** Pure Go session policy issues opaque short-lived tokens, stores only digests, authenticates by verified player identity and supports revocation | `server/domain/auth.go` covers wrong-token/expiry/revocation behavior; distributed revocation, account/IP limits, request limits and production session persistence remain | | 8.9 `[D:8.4,8.7]` | **IN PROGRESS.** Pure Go join policy binds SteamID/player/match/server/team/slot/protocol/expiry, rejects duplicate roster slots, permits same-identity reclaim through backend loss and fences prior server-owned generations | `server/domain/reconnect.go` covers SteamID/server/slot binding, expiry, repeated reclaim, grace boundary and old-generation fencing; signed token issuance/verification, persistent leases, Godot `hello` transport and production integration remain | -| 8.10 `[D:8.5,8.31]` | **IN PROGRESS.** Dependency-free workload credential policy validates an adapter-verified signature, issuer/audience/time bounds, namespace/service account, pod UID, GameServer UID, allocation ID, match ID and server ID before result submission | `server/domain/workload.go` and adversarial tests reject every binding mutation, missing/unverified signature and time boundary; `server/testkit/pipeline_test.go` carries allocation identity through the offline result path; projected-token/JWT adapter, trusted-cluster verification and live duplicate/conflict alerting remain | +| 8.10 `[D:8.5,8.31]` | **IN PROGRESS.** Dependency-free workload credential policy validates an adapter-verified signature, issuer/audience/time bounds, namespace/service account, pod UID, GameServer UID, allocation ID, match ID and server ID before result submission; `server/workload` now parses projected compact JWTs, rejects unsigned/malformed/ambiguous tokens, verifies signing input through an injected trust callback, and delegates exact binding to the domain policy | `server/domain/workload.go`, `server/workload/jwt.go` and adversarial tests reject every binding mutation, missing/unverified signature, `none`/malformed JWT, ambiguous audience and time boundary; `server/testkit/pipeline_test.go` carries allocation identity through the offline result path; trusted-cluster key verification and live duplicate/conflict alerting remain | | 8.11 `[D:8.1]` | **DONE.** Write the threat model: forged clients/replay/queues/results, floods/bots, pod/insider compromise, gameplay and API DDoS, SDR signing-key theft, dependencies and denial-of-wallet | [`docs/THREAT-MODEL.md`](docs/THREAT-MODEL.md) records prevention, detection/response, owner and residual risk for every threat; it separates offline CA/online signer, client/game-server/PostgreSQL/Redis trust boundaries and recovery behavior | | 8.12 `[D:8.11]` | **IN PROGRESS.** Provider-neutral Kubernetes baseline enforces restricted namespace admission, non-root/read-only/no-capability workloads, separate service accounts, allocator-only RBAC, default-deny networking and explicit edge/data/DNS/Agones flows; application manifests consume externally populated Secret objects; the Go API now has an optional bounded per-replica rate-limit/429 boundary | `deploy/k8s/base/` plus `server/security/test_kubernetes_policies.py`, `server/api/rate_limit.go` and adversarial tests cover static hardening, secret-reference invariants, fixed-window limits and bounded key memory; private-store provisioning, distributed/global quotas, edge DDoS/WAF/origin shielding, WebSocket limits, overload shedding, encrypted backups and live policy/load tests remain | | 8.13 `[D:8.12]` | **IN PROGRESS.** Docker/Kubernetes references are digest-pinned, a dependency-free checker rejects mutable tags/plaintext credentials and concrete release overlays can reject template digests; release documentation defines SBOM, dependency/image scanning, signing, admission verification and a 24-hour critical-fix SLA | `scripts/verify_supply_chain.py`, `server/security/test_supply_chain.py`, `docs/SUPPLY-CHAIN.md` and `.github/workflows/supply-chain.yml` cover repository policy and provenance requirements; registry SBOM/scan/sign/admission execution and a concrete production overlay remain | diff --git a/server/workload/jwt.go b/server/workload/jwt.go new file mode 100644 index 00000000..b032f385 --- /dev/null +++ b/server/workload/jwt.go @@ -0,0 +1,134 @@ +// Package workload adapts projected JWT workload credentials to the strict +// domain policy. JWT signature/key trust stays injected at this boundary. +package workload + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +type SignatureVerifier func(signingInput, signature []byte) bool + +// ParseAndValidate parses a compact JWT, verifies its signature before domain +// validation, and returns only the exact one-allocation binding accepted by +// the policy. It intentionally does not fetch keys or trust an alg claim. +func ParseAndValidate(token string, expected domain.WorkloadBinding, verify SignatureVerifier, now time.Time) (domain.WorkloadBinding, error) { + header, claims, signingInput, signature, err := parse(token) + if err != nil || header.Alg == "" || strings.EqualFold(header.Alg, "none") || verify == nil || !verify(signingInput, signature) { + return domain.WorkloadBinding{}, domain.ErrWorkloadCredential + } + credential, err := claims.credential(signature) + if err != nil { + return domain.WorkloadBinding{}, domain.ErrWorkloadCredential + } + policy, err := domain.NewWorkloadCredentialPolicy(expected, func(candidate domain.WorkloadCredential) bool { + return verify(signingInput, candidate.Signature) + }) + if err != nil { + return domain.WorkloadBinding{}, domain.ErrWorkloadCredential + } + return policy.Validate(credential, now) +} + +type tokenHeader struct { + Alg string `json:"alg"` +} + +type tokenClaims map[string]json.RawMessage + +func parse(token string) (tokenHeader, tokenClaims, []byte, []byte, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return tokenHeader{}, nil, nil, nil, fmt.Errorf("invalid compact token") + } + headerBytes, err := decode(parts[0]) + if err != nil { + return tokenHeader{}, nil, nil, nil, err + } + claimsBytes, err := decode(parts[1]) + if err != nil { + return tokenHeader{}, nil, nil, nil, err + } + signature, err := decode(parts[2]) + if err != nil || len(signature) == 0 { + return tokenHeader{}, nil, nil, nil, fmt.Errorf("invalid token signature") + } + var header tokenHeader + if err := json.Unmarshal(headerBytes, &header); err != nil { + return tokenHeader{}, nil, nil, nil, err + } + var claims tokenClaims + if err := json.Unmarshal(claimsBytes, &claims); err != nil { + return tokenHeader{}, nil, nil, nil, err + } + return header, claims, []byte(parts[0] + "." + parts[1]), signature, nil +} + +func (c tokenClaims) credential(signature []byte) (domain.WorkloadCredential, error) { + issuer, err := c.string("iss") + if err != nil { + return domain.WorkloadCredential{}, err + } + audience, err := c.audience() + if err != nil { + return domain.WorkloadCredential{}, err + } + issuedAt, err := c.time("iat") + if err != nil { + return domain.WorkloadCredential{}, err + } + expiresAt, err := c.time("exp") + if err != nil { + return domain.WorkloadCredential{}, err + } + values := make([]string, 7) + for i, name := range []string{"namespace", "service_account", "pod_uid", "gameserver_uid", "allocation_id", "match_id", "server_id"} { + values[i], err = c.string(name) + if err != nil { + return domain.WorkloadCredential{}, err + } + } + return domain.WorkloadCredential{Issuer: issuer, Audience: audience, IssuedAt: issuedAt, ExpiresAt: expiresAt, Namespace: values[0], ServiceAcct: values[1], PodUID: values[2], GameServerUID: values[3], AllocationID: values[4], MatchID: values[5], ServerID: values[6], Signature: signature}, nil +} + +func (c tokenClaims) string(name string) (string, error) { + var value string + raw, ok := c[name] + if !ok || json.Unmarshal(raw, &value) != nil || value == "" { + return "", fmt.Errorf("missing %s", name) + } + return value, nil +} +func (c tokenClaims) time(name string) (time.Time, error) { + var seconds float64 + raw, ok := c[name] + if !ok || json.Unmarshal(raw, &seconds) != nil || seconds <= 0 || seconds != float64(int64(seconds)) { + return time.Time{}, fmt.Errorf("invalid %s", name) + } + return time.Unix(int64(seconds), 0).UTC(), nil +} +func (c tokenClaims) audience() (string, error) { + if raw, ok := c["aud"]; ok { + var single string + if json.Unmarshal(raw, &single) == nil && single != "" { + return single, nil + } + var many []string + if json.Unmarshal(raw, &many) == nil && len(many) == 1 && many[0] != "" { + return many[0], nil + } + } + return "", fmt.Errorf("missing aud") +} +func decode(value string) ([]byte, error) { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err == nil { + return decoded, nil + } + return base64.URLEncoding.DecodeString(value) +} diff --git a/server/workload/jwt_test.go b/server/workload/jwt_test.go new file mode 100644 index 00000000..87def746 --- /dev/null +++ b/server/workload/jwt_test.go @@ -0,0 +1,66 @@ +package workload + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +func binding() domain.WorkloadBinding { + return domain.WorkloadBinding{Issuer: "https://issuer", Audience: "cosmic-result", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"} +} + +func tokenFor(t *testing.T, alg string, claims map[string]any) string { + t.Helper() + header, _ := json.Marshal(map[string]string{"alg": alg, "typ": "JWT"}) + payload, _ := json.Marshal(claims) + encode := func(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) } + return encode(header) + "." + encode(payload) + "." + encode([]byte("signature")) +} + +func validClaims() map[string]any { + return map[string]any{"iss": "https://issuer", "aud": "cosmic-result", "iat": float64(999), "exp": float64(1001), "namespace": "games", "service_account": "match-server", "pod_uid": "pod-1", "gameserver_uid": "gs-1", "allocation_id": "allocation-1", "match_id": "match-1", "server_id": "server-1"} +} + +func TestParseAndValidateVerifiesJWTBeforeReturningBinding(t *testing.T) { + token := tokenFor(t, "RS256", validClaims()) + wantSigning := strings.Join(strings.Split(token, ".")[:2], ".") + got, err := ParseAndValidate(token, binding(), func(signingInput, signature []byte) bool { + return string(signingInput) == wantSigning && string(signature) == "signature" + }, time.Unix(1000, 0)) + if err != nil || got != binding() { + t.Fatalf("binding=%+v err=%v", got, err) + } +} + +func TestParseAndValidateRejectsUnsignedMalformedAndMutatedTokens(t *testing.T) { + cases := []string{tokenFor(t, "none", validClaims()), tokenFor(t, "RS256", validClaims())[:10], tokenFor(t, "RS256", validClaims())} + for i, token := range cases { + _, err := ParseAndValidate(token, binding(), func([]byte, []byte) bool { return i != 2 }, time.Unix(1000, 0)) + if err == nil { + t.Fatalf("case %d accepted", i) + } + } + claims := validClaims() + claims["server_id"] = "other" + if _, err := ParseAndValidate(tokenFor(t, "RS256", claims), binding(), func([]byte, []byte) bool { return true }, time.Unix(1000, 0)); err == nil { + t.Fatal("mutated binding accepted") + } +} + +func TestParseAndValidateRejectsBoundaryExpiryAndMultiAudience(t *testing.T) { + claims := validClaims() + claims["exp"] = float64(1000) + if _, err := ParseAndValidate(tokenFor(t, "RS256", claims), binding(), func([]byte, []byte) bool { return true }, time.Unix(1000, 0)); err == nil { + t.Fatal("expiry boundary accepted") + } + claims = validClaims() + claims["aud"] = []string{"other", "cosmic-result"} + if _, err := ParseAndValidate(tokenFor(t, "RS256", claims), binding(), func([]byte, []byte) bool { return true }, time.Unix(1000, 0)); err == nil { + t.Fatal("ambiguous audience accepted") + } +}