From 520613aab03773272f42030862ddc3cd3d51542a Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:51:21 +0100 Subject: [PATCH] feat(multiplayer): implement WorkloadVerify without a Kubernetes trust boundary WorkloadVerify (api.Service.WorkloadVerify) was permanently unwired: both /v1/servers/{id}/register and /v1/servers/{id}/result always 503, because the only design considered so far was verifying a Kubernetes-projected service-account JWT (server/workload/jwt.go), which needs a live cluster's TokenReview/JWKS endpoint to validate against safely -- something this sandbox cannot do without guessing at a trust boundary. The API layer doesn't actually require that specific mechanism. serverMutation only compares WorkloadBinding.ServerID and .MatchID (server/api/service.go); AdvanceServerRegistration only uses .MatchID/.ServerID/.AllocationID. Nothing downstream needs Namespace/ServiceAcct/PodUID/GameServerUID populated. This adds a self-contained alternative: a short-lived, HMAC-signed token the control plane mints and verifies with a secret only it holds (server/workload/ signed_token.go), the same trust model domain.SessionStore already uses for player sessions elsewhere in this codebase. It needs no cluster to verify -- signature + expiry is fully self-contained and unit-testable. The design's soundness rests on the delivery channel, not the crypto: the token is meant to reach the allocated GameServer via the same Agones GameServerAllocation annotation channel allocation.go already uses for match-id/allocation-id, readable only by that pod's own local SDK sidecar. A caller presenting this token has already proven, via that channel, that it is the pod Agones allocated. (Wiring the actual annotation delivery -- extending agones.Client.Allocate and the supervisor's token source -- is a separate, follow-up change; this commit lands the verification core it depends on.) store.AllocationBindingStillValid adds defense-in-depth on top of signature and expiry: it cross-checks the token's claims against the durable allocations table (append-only, never leaves 'ALLOCATED'), so a validly-signed token naming an allocation that was never recorded -- or a real allocation id paired with a mismatched match/server -- is still rejected. api.WorkloadVerifierFromSignedToken wires the two together and is now plugged into cmd/control-plane (new --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET flag; a startup warning is logged if it's left unset, since the route then stays 503 exactly as before) and cmd/testkit-api (fixed test secret, since that binary is test-only already). Verified: new unit tests in server/workload (signature tamper, wrong secret, expiry boundary, malformed input) and a new Postgres integration suite in server/api (real allocation row, real signed token, acceptance / unknown- allocation rejection / mismatched-triple rejection / the previously-503 Service.WorkloadVerify field itself) -- both run clean with -race across multiple passes against a live postgres:17-alpine container. Full `go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and `go test -tags integration ./... -race` both clean. --- server/api/store_adapters.go | 38 ++++ .../api/workload_verifier_integration_test.go | 168 ++++++++++++++++++ server/cmd/control-plane/main.go | 9 +- server/cmd/control-plane/main_test.go | 25 +-- server/cmd/testkit-api/main.go | 9 + server/store/allocation_binding_sql.go | 36 ++++ server/workload/signed_token.go | 126 +++++++++++++ server/workload/signed_token_test.go | 100 +++++++++++ 8 files changed, 497 insertions(+), 14 deletions(-) create mode 100644 server/api/workload_verifier_integration_test.go create mode 100644 server/store/allocation_binding_sql.go create mode 100644 server/workload/signed_token.go create mode 100644 server/workload/signed_token_test.go diff --git a/server/api/store_adapters.go b/server/api/store_adapters.go index 9359a210..48ea51c3 100644 --- a/server/api/store_adapters.go +++ b/server/api/store_adapters.go @@ -3,10 +3,12 @@ package api import ( "context" "database/sql" + "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/store" + "github.com/cosmic-clash/cosmic-clash/server/workload" ) // AssignmentProviderFromStore adapts the durable player-scoped assignment @@ -71,3 +73,39 @@ func ServerRegistrarFromStore(db *sql.DB) ServerRegistrar { } return postgresServerRegistrar{db: db} } + +// WorkloadVerifierFromSignedToken builds WorkloadVerify from a control-plane +// -owned signed token instead of a Kubernetes-projected JWT (see +// workload/signed_token.go for why: it needs no live cluster to verify). +// secret must be kept out of source control (env var in cmd/control-plane); +// an empty secret returns nil so a misconfigured deployment fails the same +// way an unwired verifier already does today (503, not a silent bypass). +func WorkloadVerifierFromSignedToken(secret []byte, db *sql.DB) WorkloadVerifier { + if len(secret) == 0 || db == nil { + return nil + } + return func(token string, now time.Time) (domain.WorkloadBinding, error) { + claims, err := workload.ParseSignedWorkloadToken(secret, token, now) + if err != nil { + return domain.WorkloadBinding{}, err + } + // WorkloadVerifier has no context parameter (see its type in + // service.go) so the durable cross-check below cannot inherit the + // caller's request context; bound it locally instead of running + // unbounded against context.Background(). + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ok, err := store.AllocationBindingStillValid(ctx, db, claims.AllocationID, claims.MatchID, claims.ServerID) + if err != nil { + return domain.WorkloadBinding{}, err + } + if !ok { + return domain.WorkloadBinding{}, fmt.Errorf("signed workload token names an allocation that is no longer valid") + } + return domain.WorkloadBinding{ + AllocationID: claims.AllocationID, + MatchID: claims.MatchID, + ServerID: claims.ServerID, + }, nil + } +} diff --git a/server/api/workload_verifier_integration_test.go b/server/api/workload_verifier_integration_test.go new file mode 100644 index 00000000..b2d52648 --- /dev/null +++ b/server/api/workload_verifier_integration_test.go @@ -0,0 +1,168 @@ +//go:build integration + +package api + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" + "github.com/cosmic-clash/cosmic-clash/server/migrations" + "github.com/cosmic-clash/cosmic-clash/server/store" + "github.com/cosmic-clash/cosmic-clash/server/workload" + _ "github.com/jackc/pgx/v5/stdlib" +) + +// This binary is deliberately opt-in, matching store's integration suite: it +// requires a disposable PostgreSQL instance supplied by +// scripts/run_postgres_integration.sh. +func openIntegrationPostgres(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN") + if dsn == "" { + t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open PostgreSQL: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + db.Close() + t.Fatalf("ping PostgreSQL: %v", err) + } + t.Cleanup(func() { db.Close() }) + migrationDir := os.Getenv("COSMIC_CLASH_MIGRATIONS_DIR") + if migrationDir == "" { + migrationDir = filepath.Join("..", "migrations") + } + if err := migrations.Apply(ctx, db, migrationDir); err != nil { + t.Fatalf("apply migrations: %v", err) + } + return db +} + +// seedRealAllocation claims a real ready server and allocation row, exactly +// the durable state a signed workload token must later be cross-checked +// against (see store.AllocationBindingStillValid). +func seedRealAllocation(t *testing.T, db *sql.DB, allocationID, matchID string, now time.Time) domain.Allocation { + t.Helper() + ctx := context.Background() + serverID := "server-" + allocationID + if err := store.RegisterReadyServer(ctx, db, domain.ReadyServer{ServerID: serverID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}, now); err != nil { + t.Fatalf("register ready server: %v", err) + } + allocation, err := store.ClaimAllocation(ctx, db, domain.AllocationRequest{AllocationID: allocationID, MatchID: matchID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, now) + if err != nil { + t.Fatalf("claim allocation: %v", err) + } + return allocation +} + +// TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation proves the full +// wired path: a token issued by workload.IssueSignedWorkloadToken for a real +// allocation row verifies successfully through +// WorkloadVerifierFromSignedToken and returns a binding matching what +// serverMutation actually checks (ServerID, MatchID). This is the "wired, +// working" counterpart to cmd/control-plane's +// TestServerRoutesRequireWorkloadVerifyToBeWired, which pins the +// unconfigured-503 case. +func TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation(t *testing.T) { + db := openIntegrationPostgres(t) + now := time.Now().UTC() + allocation := seedRealAllocation(t, db, "alloc-verify-1", "match-verify-1", now) + + secret := []byte("integration-test-secret") + token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, allocation.MatchID, allocation.ServerID, now, time.Minute) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + verify := WorkloadVerifierFromSignedToken(secret, db) + if verify == nil { + t.Fatal("WorkloadVerifierFromSignedToken returned nil with a real secret and database") + } + binding, err := verify(token, now.Add(30*time.Second)) + if err != nil { + t.Fatalf("verify: %v", err) + } + if binding.ServerID != allocation.ServerID || binding.MatchID != allocation.MatchID || binding.AllocationID != allocation.AllocationID { + t.Fatalf("unexpected binding: %+v, want server=%s match=%s allocation=%s", binding, allocation.ServerID, allocation.MatchID, allocation.AllocationID) + } +} + +// TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation proves the +// durable cross-check actually runs: a validly-signed, unexpired token whose +// allocation was never recorded (e.g. superseded, or simply fabricated) must +// still be rejected. Signature and expiry checks alone are not enough. +func TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation(t *testing.T) { + db := openIntegrationPostgres(t) + now := time.Now().UTC() + secret := []byte("integration-test-secret") + token, err := workload.IssueSignedWorkloadToken(secret, "alloc-never-recorded", "match-never-recorded", "server-never-recorded", now, time.Minute) + if err != nil { + t.Fatalf("issue token: %v", err) + } + verify := WorkloadVerifierFromSignedToken(secret, db) + if _, err := verify(token, now.Add(time.Second)); err == nil { + t.Fatal("expected rejection for a token naming an allocation that was never recorded") + } +} + +// TestWorkloadVerifierFromSignedTokenRejectsAMismatchedTriple proves the +// cross-check binds all three identifiers together, not each independently: +// a real allocation's own allocation_id combined with someone else's +// match/server must still fail. +func TestWorkloadVerifierFromSignedTokenRejectsAMismatchedTriple(t *testing.T) { + db := openIntegrationPostgres(t) + now := time.Now().UTC() + allocation := seedRealAllocation(t, db, "alloc-verify-2", "match-verify-2", now) + secret := []byte("integration-test-secret") + token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, "a-different-match", allocation.ServerID, now, time.Minute) + if err != nil { + t.Fatalf("issue token: %v", err) + } + verify := WorkloadVerifierFromSignedToken(secret, db) + if _, err := verify(token, now.Add(time.Second)); err == nil { + t.Fatal("expected rejection for a real allocation id paired with the wrong match id") + } +} + +// TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap proves the +// 503-by-default gap pinned by +// cmd/control-plane.TestServerRoutesRequireWorkloadVerifyToBeWired is +// actually closed once a secret and database are wired: a Service built the +// same way newAPIHandler builds one now accepts a validly-issued token for a +// real allocation, through the exact Service.WorkloadVerify field the HTTP +// handler calls. (serverMutation's deeper match-state transition -- +// requiring the match to already be ALLOCATING -- is exercised separately by +// the store package's own allocation/match integration tests; this test's +// job is only the WorkloadVerify boundary itself.) +func TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap(t *testing.T) { + db := openIntegrationPostgres(t) + now := time.Now().UTC() + allocation := seedRealAllocation(t, db, "alloc-verify-3", "match-verify-3", now) + secret := []byte("integration-test-secret") + token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, allocation.MatchID, allocation.ServerID, now, time.Minute) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + svc := &Service{ + ServerRegistrar: ServerRegistrarFromStore(db), + WorkloadVerify: WorkloadVerifierFromSignedToken(secret, db), + Now: func() time.Time { return now.Add(time.Second) }, + } + binding, err := svc.WorkloadVerify(token, now.Add(time.Second)) + if err != nil { + t.Fatalf("WorkloadVerify rejected a validly-issued token for a real allocation: %v", err) + } + if binding.ServerID != allocation.ServerID { + t.Fatalf("binding.ServerID = %q, want %q", binding.ServerID, allocation.ServerID) + } +} diff --git a/server/cmd/control-plane/main.go b/server/cmd/control-plane/main.go index ada0bd03..b78549bc 100644 --- a/server/cmd/control-plane/main.go +++ b/server/cmd/control-plane/main.go @@ -27,6 +27,7 @@ func main() { redisAddr := flag.String("redis-addr", os.Getenv("COSMIC_CLASH_REDIS_ADDR"), "optional Redis address for the candidate projection") redisPrefix := flag.String("redis-prefix", envOrDefault("COSMIC_CLASH_REDIS_PREFIX", "cosmic-clash"), "Redis key prefix") redisTTL := flag.Duration("redis-ttl", 60*time.Second, "TTL for transient candidate projection entries") + workloadSecret := flag.String("workload-secret", os.Getenv("COSMIC_CLASH_WORKLOAD_SECRET"), "HMAC secret for control-plane-issued workload tokens (see workload/signed_token.go); server registration/result submission return 503 until this is set") flag.Parse() if *role != "api" { fatalf("unsupported role %q (only api is implemented)", *role) @@ -57,7 +58,10 @@ func main() { defer redisClient.Close() candidateIndex = store.RedisCandidateIndex{Client: redisClient, Prefix: *redisPrefix, TTL: *redisTTL} } - server := &http.Server{Addr: *listen, Handler: newAPIHandler(db, candidateIndex), ReadHeaderTimeout: 5 * time.Second} + if *workloadSecret == "" { + fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503") + } + server := &http.Server{Addr: *listen, Handler: newAPIHandler(db, *workloadSecret, candidateIndex), ReadHeaderTimeout: 5 * time.Second} serveErr := make(chan error, 1) go func() { serveErr <- server.ListenAndServe() }() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -76,7 +80,7 @@ func main() { } } -func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler { +func newAPIHandler(db *sql.DB, workloadSecret string, indexes ...api.CandidateIndex) http.Handler { var candidateIndex api.CandidateIndex if len(indexes) > 0 { candidateIndex = indexes[0] @@ -93,6 +97,7 @@ func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler { Assignment: api.AssignmentProviderFromStore(db), CandidateIndex: candidateIndex, ProbeRecorder: store.PostgresQueue{DB: db}, + WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(workloadSecret), db), Now: func() time.Time { return time.Now().UTC() }, Log: logEvent, }).Handler() diff --git a/server/cmd/control-plane/main_test.go b/server/cmd/control-plane/main_test.go index de7115c3..eb2c122e 100644 --- a/server/cmd/control-plane/main_test.go +++ b/server/cmd/control-plane/main_test.go @@ -9,24 +9,25 @@ import ( func TestAPIHandlerExposesHealthWithoutDatabase(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() - newAPIHandler(nil).ServeHTTP(rec, req) + newAPIHandler(nil, "").ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("health status = %d", rec.Code) } } -// TestServerRoutesRequireWorkloadVerifyToBeWired pins a real, known gap -// rather than leaving it silent: newAPIHandler wires ServerRegistrar and -// ResultSubmitter, but never a WorkloadVerify -- and Service.serverMutation -// treats a nil WorkloadVerify as fatal for BOTH the register and result -// routes, regardless of whether their own dependency is present. So today, -// in the actual running binary, POST /v1/servers/{id}/register and -// /v1/servers/{id}/result both always 503, independent of a real database or -// real request. This test should start failing (and be updated, not -// deleted) the day a real WorkloadVerify is wired -- that's the intended -// signal, not a bug in the test. +// TestServerRoutesRequireWorkloadVerifyToBeWired pins the deployment +// misconfiguration case: newAPIHandler wires ServerRegistrar and +// ResultSubmitter, but WorkloadVerifierFromSignedToken deliberately returns +// nil whenever the secret or the database is missing (see +// api.WorkloadVerifierFromSignedToken) rather than silently accepting every +// caller. Service.serverMutation treats a nil WorkloadVerify as fatal for +// BOTH the register and result routes. This test should start failing (and +// be updated, not deleted) the day this path stops 503ing with an empty +// secret and a nil database -- that's the intended signal, not a bug in the +// test. See TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation in the +// api package's Postgres integration suite for the wired, working path. func TestServerRoutesRequireWorkloadVerifyToBeWired(t *testing.T) { - handler := newAPIHandler(nil) + handler := newAPIHandler(nil, "") for _, path := range []string{"/v1/servers/server-1/register", "/v1/servers/server-1/result"} { req := httptest.NewRequest(http.MethodPost, path, nil) req.Header.Set("Idempotency-Key", "regression-pin-key-123456") diff --git a/server/cmd/testkit-api/main.go b/server/cmd/testkit-api/main.go index 73650e72..123f13f8 100644 --- a/server/cmd/testkit-api/main.go +++ b/server/cmd/testkit-api/main.go @@ -36,6 +36,7 @@ func main() { listen := flag.String("listen", "127.0.0.1:0", "HTTP listen address; port 0 picks a free port, printed on startup") dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string") migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations") + workloadSecret := flag.String("workload-secret", envOrDefault("COSMIC_CLASH_WORKLOAD_SECRET", "testkit-workload-secret"), "HMAC secret for signed workload tokens; defaults to a fixed test value since this binary is test-only") flag.Parse() if *dsn == "" { fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required") @@ -65,6 +66,7 @@ func main() { RankedProfileProvider: store.PostgresRankedProfiles{DB: db}, Assignment: api.AssignmentProviderFromStore(db), ProbeRecorder: store.PostgresQueue{DB: db}, + WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(*workloadSecret), db), Now: func() time.Time { return time.Now().UTC() }, }).Handler() listener, err := net.Listen("tcp", *listen) @@ -107,6 +109,13 @@ func (f fakeSteamLogin) Authenticate(ctx context.Context, ticket string, _ time. return domain.VerifiedIdentity{PlayerID: playerID, SteamID: steamID}, nil } +func envOrDefault(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "testkit-api: "+format+"\n", args...) os.Exit(1) diff --git a/server/store/allocation_binding_sql.go b/server/store/allocation_binding_sql.go new file mode 100644 index 00000000..7d6ab001 --- /dev/null +++ b/server/store/allocation_binding_sql.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "database/sql" +) + +// AllocationBindingStillValidSQL cross-checks a signed workload token's +// claims against the durable allocation record before trusting it. A +// validly-signed, unexpired token alone is not proof the allocation it names +// is still the live binding for that match/server pair -- this closes that +// gap defense-in-depth. allocations rows are append-only and never leave +// 'ALLOCATED' (see allocator_sql.go), so this is a simple existence check, +// not a state-machine walk. +const AllocationBindingStillValidSQL = `SELECT 1 FROM allocations +WHERE allocation_id = $1 AND match_id = $2 AND server_id = $3 AND state = 'ALLOCATED'` + +// AllocationBindingStillValid reports whether the given (allocationID, +// matchID, serverID) triple names a real, still-allocated row. db, and every +// identifier, must be non-empty -- callers pass this an already-parsed and +// signature-verified token's claims, so empty fields here indicate a caller +// bug rather than a legitimate "not found". +func AllocationBindingStillValid(ctx context.Context, db *sql.DB, allocationID, matchID, serverID string) (bool, error) { + if db == nil || allocationID == "" || matchID == "" || serverID == "" { + return false, sql.ErrNoRows + } + var one int + err := db.QueryRowContext(ctx, AllocationBindingStillValidSQL, allocationID, matchID, serverID).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/server/workload/signed_token.go b/server/workload/signed_token.go new file mode 100644 index 00000000..3d687a86 --- /dev/null +++ b/server/workload/signed_token.go @@ -0,0 +1,126 @@ +package workload + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" +) + +// SignedWorkloadToken is a control-plane-issued bearer credential for the +// WorkloadVerify boundary (see multiplayer-next.md 8.10). It exists because +// the obvious approach -- verifying a Kubernetes-projected service-account +// JWT via TokenReview/JWKS (see jwt.go, ParseAndValidate) -- needs a live +// cluster to validate against and so cannot be built or tested here. +// +// This sidesteps that requirement entirely: the control plane signs its own +// short-lived token over (allocation_id, match_id, server_id, expiry) with a +// secret only it holds, exactly the way domain.SessionStore already mints +// player session tokens elsewhere in this codebase. It needs no Kubernetes +// trust boundary to verify -- HMAC signature plus expiry is self-contained. +// +// The delivery channel is what makes this safe despite not proving pod +// identity the way a Kubernetes-issued token would: the token is meant to be +// handed to the allocated GameServer via the same Agones GameServerAllocation +// annotation channel allocation.go already uses for match-id/allocation-id +// (see agones/allocation.go), which only the actually-allocated pod's local +// SDK sidecar can read. A caller who can present this token has already +// proven, via that channel, that it is the pod Agones allocated. +type SignedWorkloadToken struct { + AllocationID string `json:"a"` + MatchID string `json:"m"` + ServerID string `json:"s"` + ExpiresAt time.Time `json:"e"` +} + +var ( + ErrEmptyWorkloadSecret = errors.New("workload token signing secret is empty") + ErrMalformedToken = errors.New("malformed signed workload token") + ErrTokenSignature = errors.New("signed workload token signature mismatch") + ErrTokenExpired = errors.New("signed workload token expired") + ErrTokenClaims = errors.New("signed workload token missing required claims") +) + +// IssueSignedWorkloadToken produces a compact "payload.signature" token +// binding the three identifiers the API layer actually checks (see +// api.Service's WorkloadVerify call site: it only compares ServerID and +// MatchID on the returned domain.WorkloadBinding). now must be non-zero and +// ttl must be positive so a token is never silently issued already-expired. +func IssueSignedWorkloadToken(secret []byte, allocationID, matchID, serverID string, now time.Time, ttl time.Duration) (string, error) { + if len(secret) == 0 { + return "", ErrEmptyWorkloadSecret + } + if allocationID == "" || matchID == "" || serverID == "" { + return "", ErrTokenClaims + } + if now.IsZero() || ttl <= 0 { + return "", fmt.Errorf("issue signed workload token: now and ttl must be valid") + } + claims := SignedWorkloadToken{ + AllocationID: allocationID, + MatchID: matchID, + ServerID: serverID, + ExpiresAt: now.Add(ttl).UTC(), + } + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal signed workload token: %w", err) + } + payloadEnc := base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(payloadEnc)) + sigEnc := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return payloadEnc + "." + sigEnc, nil +} + +// ParseSignedWorkloadToken verifies the signature in constant time, checks +// expiry against now, and returns the claims. It never trusts the payload +// before the signature is verified. +func ParseSignedWorkloadToken(secret []byte, token string, now time.Time) (SignedWorkloadToken, error) { + if len(secret) == 0 { + return SignedWorkloadToken{}, ErrEmptyWorkloadSecret + } + dot := -1 + for i := 0; i < len(token); i++ { + if token[i] == '.' { + dot = i + break + } + } + if dot <= 0 || dot == len(token)-1 { + return SignedWorkloadToken{}, ErrMalformedToken + } + payloadEnc, sigEnc := token[:dot], token[dot+1:] + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(payloadEnc)) + expectedSig := mac.Sum(nil) + gotSig, err := base64.RawURLEncoding.DecodeString(sigEnc) + if err != nil { + return SignedWorkloadToken{}, ErrMalformedToken + } + if subtle.ConstantTimeCompare(expectedSig, gotSig) != 1 { + return SignedWorkloadToken{}, ErrTokenSignature + } + payload, err := base64.RawURLEncoding.DecodeString(payloadEnc) + if err != nil { + return SignedWorkloadToken{}, ErrMalformedToken + } + var claims SignedWorkloadToken + if err := json.Unmarshal(payload, &claims); err != nil { + return SignedWorkloadToken{}, ErrMalformedToken + } + if claims.AllocationID == "" || claims.MatchID == "" || claims.ServerID == "" || claims.ExpiresAt.IsZero() { + return SignedWorkloadToken{}, ErrTokenClaims + } + if now.IsZero() { + return SignedWorkloadToken{}, fmt.Errorf("parse signed workload token: now must be valid") + } + if !now.Before(claims.ExpiresAt) { + return SignedWorkloadToken{}, ErrTokenExpired + } + return claims, nil +} diff --git a/server/workload/signed_token_test.go b/server/workload/signed_token_test.go new file mode 100644 index 00000000..3ad99228 --- /dev/null +++ b/server/workload/signed_token_test.go @@ -0,0 +1,100 @@ +package workload + +import ( + "errors" + "testing" + "time" +) + +func TestSignedWorkloadTokenRoundTrips(t *testing.T) { + secret := []byte("test-secret") + now := time.Unix(1_700_000_000, 0).UTC() + token, err := IssueSignedWorkloadToken(secret, "alloc-1", "match-1", "server-1", now, time.Minute) + if err != nil { + t.Fatalf("issue: %v", err) + } + claims, err := ParseSignedWorkloadToken(secret, token, now.Add(30*time.Second)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.AllocationID != "alloc-1" || claims.MatchID != "match-1" || claims.ServerID != "server-1" { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +func TestSignedWorkloadTokenRejectsExpiry(t *testing.T) { + secret := []byte("test-secret") + now := time.Unix(1_700_000_000, 0).UTC() + token, err := IssueSignedWorkloadToken(secret, "alloc-1", "match-1", "server-1", now, time.Minute) + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := ParseSignedWorkloadToken(secret, token, now.Add(61*time.Second)); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } + // Boundary: exactly at expiry must also be rejected (Before, not + // Before-or-equal), matching the proposal-expiry read boundary + // convention used elsewhere in this codebase. + if _, err := ParseSignedWorkloadToken(secret, token, now.Add(time.Minute)); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expected ErrTokenExpired at the boundary, got %v", err) + } +} + +func TestSignedWorkloadTokenRejectsTamperedPayload(t *testing.T) { + secret := []byte("test-secret") + now := time.Unix(1_700_000_000, 0).UTC() + token, err := IssueSignedWorkloadToken(secret, "alloc-1", "match-1", "server-1", now, time.Minute) + if err != nil { + t.Fatalf("issue: %v", err) + } + tampered := token[:len(token)-4] + "AAAA" + if _, err := ParseSignedWorkloadToken(secret, tampered, now); !errors.Is(err, ErrTokenSignature) && !errors.Is(err, ErrMalformedToken) { + t.Fatalf("expected signature/malformed rejection, got %v", err) + } +} + +func TestSignedWorkloadTokenRejectsWrongSecret(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + token, err := IssueSignedWorkloadToken([]byte("secret-a"), "alloc-1", "match-1", "server-1", now, time.Minute) + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := ParseSignedWorkloadToken([]byte("secret-b"), token, now); !errors.Is(err, ErrTokenSignature) { + t.Fatalf("expected ErrTokenSignature, got %v", err) + } +} + +func TestSignedWorkloadTokenRejectsMalformedInput(t *testing.T) { + secret := []byte("test-secret") + now := time.Unix(1_700_000_000, 0).UTC() + for _, token := range []string{"", "no-dot-here", ".missing-payload", "missing-signature.", "!!!.!!!"} { + if _, err := ParseSignedWorkloadToken(secret, token, now); err == nil { + t.Fatalf("token %q: expected an error, got nil", token) + } + } +} + +func TestIssueSignedWorkloadTokenRejectsInvalidInput(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + cases := []struct { + name string + secret []byte + allocationID string + matchID string + serverID string + now time.Time + ttl time.Duration + }{ + {"empty secret", nil, "a", "m", "s", now, time.Minute}, + {"empty allocation id", []byte("k"), "", "m", "s", now, time.Minute}, + {"empty match id", []byte("k"), "a", "", "s", now, time.Minute}, + {"empty server id", []byte("k"), "a", "m", "", now, time.Minute}, + {"zero now", []byte("k"), "a", "m", "s", time.Time{}, time.Minute}, + {"non-positive ttl", []byte("k"), "a", "m", "s", now, 0}, + } + for _, c := range cases { + if _, err := IssueSignedWorkloadToken(c.secret, c.allocationID, c.matchID, c.serverID, c.now, c.ttl); err == nil { + t.Fatalf("%s: expected an error, got nil", c.name) + } + } +}