diff --git a/cmd/receiver/main.go b/cmd/receiver/main.go index 42e688e..c540736 100644 --- a/cmd/receiver/main.go +++ b/cmd/receiver/main.go @@ -24,6 +24,7 @@ func main() { dbPath := flag.String("db", defaultDBPath, "SQLite database path") readToken := flag.String("read-token", os.Getenv("RECEIVER_READ_TOKEN"), "Pre-shared token for read endpoints (or set RECEIVER_READ_TOKEN)") hmacKey := flag.String("hmac-key", os.Getenv("RECEIVER_HMAC_KEY"), "Secret key for push token generation/validation (or set RECEIVER_HMAC_KEY)") + tokenTTL := flag.Duration("token-ttl", 2*time.Hour, "Time-to-live for push tokens (default 2h)") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ @@ -37,7 +38,7 @@ func main() { } defer func() { _ = store.Close() }() - handler := receiver.NewHandler(store, logger, *readToken, *hmacKey) + handler := receiver.NewHandler(store, logger, *readToken, *hmacKey, *tokenTTL) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 326e3d5..f21fa6f 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -32,7 +32,7 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) t.Fatalf("NewStore() error = %v", err) } - handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey) + handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey, 0) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -46,9 +46,9 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) return store, server, cleanup } -// generatePushToken generates a scoped push token for an execution context +// generatePushToken generates a push token for an execution context func generatePushToken(exec summary.ExecutionContext) string { - return receiver.GenerateScopedToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job) + return receiver.GenerateToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job) } func TestPushClientToReceiver(t *testing.T) { @@ -166,8 +166,8 @@ func TestPushClientIntegration(t *testing.T) { t.Setenv("GITHUB_JOB", "push-job") t.Setenv("GITHUB_RUN_ID", "push-run-456") - // Generate scoped push token - pushToken := receiver.GenerateScopedToken(testHMACKey, "push-client-org", "push-client-repo", "push-test.yml", "push-job") + // Generate push token + pushToken := receiver.GenerateToken(testHMACKey, "push-client-org", "push-client-repo", "push-test.yml", "push-job") // Create push client with token - it reads execution context from env vars pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", pushToken) @@ -371,7 +371,7 @@ func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*recei t.Fatalf("NewStore() error = %v", err) } - handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey) + handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey, 0) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/internal/receiver/handler.go b/internal/receiver/handler.go index eb1069d..57c09b5 100644 --- a/internal/receiver/handler.go +++ b/internal/receiver/handler.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "strings" + "time" ) // Handler handles HTTP requests for the metrics receiver @@ -17,13 +18,18 @@ type Handler struct { logger *slog.Logger readToken string // Pre-shared token for read endpoint authentication hmacKey string // Separate key for HMAC-based push token generation/validation + tokenTTL time.Duration } // NewHandler creates a new HTTP handler with the given store. // readToken authenticates read endpoints and the token generation endpoint. // hmacKey is the secret used to derive scoped push tokens. -func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string) *Handler { - return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey} +// tokenTTL specifies how long push tokens are valid (0 uses DefaultTokenTTL). +func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string, tokenTTL time.Duration) *Handler { + if tokenTTL == 0 { + tokenTTL = DefaultTokenTTL + } + return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey, tokenTTL: tokenTTL} } // RegisterRoutes registers all HTTP routes on the given mux @@ -88,7 +94,7 @@ func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) { return } - token := GenerateScopedToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job) + token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TokenResponse{Token: token}) @@ -117,7 +123,7 @@ func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec } token := strings.TrimPrefix(authHeader, bearerPrefix) - if !ValidateScopedToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) { + if !ValidateToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job, h.tokenTTL) { h.logger.Warn("invalid push token", slog.String("path", r.URL.Path)) http.Error(w, "invalid token", http.StatusUnauthorized) return false diff --git a/internal/receiver/handler_test.go b/internal/receiver/handler_test.go index 70d12d9..12b327e 100644 --- a/internal/receiver/handler_test.go +++ b/internal/receiver/handler_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "edp.buildth.ing/DevFW-CICD/forgejo-runner-optimiser/internal/summary" @@ -25,7 +26,7 @@ func TestHandler_ReceiveMetrics(t *testing.T) { Job: "build", RunID: "run-123", } - pushToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) + pushToken := GenerateToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) payload := MetricsPayload{ Execution: exec, @@ -264,8 +265,13 @@ func TestHandler_GenerateToken(t *testing.T) { if resp.Token == "" { t.Error("expected non-empty token") } - if len(resp.Token) != 64 { - t.Errorf("token length = %d, want 64", len(resp.Token)) + // Token format is "timestamp:hmac" where hmac is 64 hex chars + parts := strings.SplitN(resp.Token, ":", 2) + if len(parts) != 2 { + t.Errorf("token should have format 'timestamp:hmac', got %q", resp.Token) + } + if len(parts[1]) != 64 { + t.Errorf("HMAC part length = %d, want 64", len(parts[1])) } } @@ -357,8 +363,8 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) { RunID: "run-1", } - validToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) - wrongScopeToken := GenerateScopedToken(readToken, "other-org", "repo", "ci.yml", "build") + validToken := GenerateToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) + wrongScopeToken := GenerateToken(readToken, "other-org", "repo", "ci.yml", "build") tests := []struct { name string @@ -448,7 +454,7 @@ func newTestHandler(t *testing.T) (*Handler, func()) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - handler := NewHandler(store, logger, "", "") // no auth — endpoints will reject + handler := NewHandler(store, logger, "", "", 0) // no auth — endpoints will reject return handler, func() { _ = store.Close() } } @@ -467,7 +473,7 @@ func newTestHandlerWithKeys(t *testing.T, readToken, hmacKey string) (*Handler, } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - handler := NewHandler(store, logger, readToken, hmacKey) + handler := NewHandler(store, logger, readToken, hmacKey, 0) // 0 uses DefaultTokenTTL return handler, func() { _ = store.Close() } } diff --git a/internal/receiver/token.go b/internal/receiver/token.go index 087546c..47721fa 100644 --- a/internal/receiver/token.go +++ b/internal/receiver/token.go @@ -1,5 +1,5 @@ // ABOUTME: HMAC-SHA256 token generation and validation for scoped push authentication. -// ABOUTME: Tokens are derived from a key + scope, enabling stateless validation without DB storage. +// ABOUTME: Tokens are derived from a key + scope + timestamp, enabling stateless validation with expiration. package receiver import ( @@ -7,19 +7,71 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" + "fmt" + "strconv" + "strings" + "time" ) -// GenerateScopedToken computes an HMAC-SHA256 token scoped to a specific org/repo/workflow/job. -// The canonical input is "v1\x00\x00\x00\x00". -func GenerateScopedToken(key, org, repo, workflow, job string) string { - mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job)) - return hex.EncodeToString(mac.Sum(nil)) +// DefaultTokenTTL is the default time-to-live for push tokens. +const DefaultTokenTTL = 2 * time.Hour + +// GenerateToken creates a token with embedded timestamp for expiration support. +// Format: ":" +func GenerateToken(key, org, repo, workflow, job string) string { + return GenerateTokenAt(key, org, repo, workflow, job, time.Now()) } -// ValidateScopedToken checks whether a token matches the expected HMAC for the given scope. -// Uses constant-time comparison to prevent timing attacks. -func ValidateScopedToken(key, token, org, repo, workflow, job string) bool { - expected := GenerateScopedToken(key, org, repo, workflow, job) - return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1 +// GenerateTokenAt creates a token with the specified timestamp. +// The HMAC input is "v1\x00\x00\x00\x00\x00". +func GenerateTokenAt(key, org, repo, workflow, job string, timestamp time.Time) string { + ts := strconv.FormatInt(timestamp.Unix(), 10) + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job + "\x00" + ts)) + return ts + ":" + hex.EncodeToString(mac.Sum(nil)) +} + +// ValidateToken validates a token and checks expiration. +// Returns true if the token is valid and not expired. +func ValidateToken(key, token, org, repo, workflow, job string, ttl time.Duration) bool { + return ValidateTokenAt(key, token, org, repo, workflow, job, ttl, time.Now()) +} + +// ValidateTokenAt validates a token against a specific reference time. +func ValidateTokenAt(key, token, org, repo, workflow, job string, ttl time.Duration, now time.Time) bool { + parts := strings.SplitN(token, ":", 2) + if len(parts) != 2 { + return false + } + + tsStr, hmacHex := parts[0], parts[1] + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return false + } + + tokenTime := time.Unix(ts, 0) + if now.Sub(tokenTime) > ttl { + return false + } + + // Recompute expected HMAC + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job + "\x00" + tsStr)) + expected := hex.EncodeToString(mac.Sum(nil)) + + return subtle.ConstantTimeCompare([]byte(hmacHex), []byte(expected)) == 1 +} + +// ParseTokenTimestamp extracts the timestamp from a timestamped token without validating it. +func ParseTokenTimestamp(token string) (time.Time, error) { + parts := strings.SplitN(token, ":", 2) + if len(parts) != 2 { + return time.Time{}, fmt.Errorf("invalid token format") + } + ts, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("invalid timestamp: %w", err) + } + return time.Unix(ts, 0), nil } diff --git a/internal/receiver/token_test.go b/internal/receiver/token_test.go index 2140ecd..897ab1a 100644 --- a/internal/receiver/token_test.go +++ b/internal/receiver/token_test.go @@ -1,20 +1,35 @@ package receiver import ( - "encoding/hex" + "strconv" + "strings" "testing" + "time" ) -func TestGenerateScopedToken_Deterministic(t *testing.T) { - token1 := GenerateScopedToken("key", "org", "repo", "wf", "job") - token2 := GenerateScopedToken("key", "org", "repo", "wf", "job") +func TestGenerateToken_Format(t *testing.T) { + token := GenerateToken("key", "org", "repo", "wf", "job") + parts := strings.SplitN(token, ":", 2) + if len(parts) != 2 { + t.Fatalf("token should have format 'timestamp:hmac', got %q", token) + } + if len(parts[1]) != 64 { + t.Errorf("HMAC part length = %d, want 64", len(parts[1])) + } +} + +func TestGenerateTokenAt_Deterministic(t *testing.T) { + ts := time.Unix(1700000000, 0) + token1 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) + token2 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) if token1 != token2 { t.Errorf("tokens differ: %q vs %q", token1, token2) } } -func TestGenerateScopedToken_ScopePinning(t *testing.T) { - base := GenerateScopedToken("key", "org", "repo", "wf", "job") +func TestGenerateTokenAt_ScopePinning(t *testing.T) { + ts := time.Unix(1700000000, 0) + base := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) variants := []struct { name string @@ -31,7 +46,7 @@ func TestGenerateScopedToken_ScopePinning(t *testing.T) { for _, v := range variants { t.Run(v.name, func(t *testing.T) { - token := GenerateScopedToken("key", v.org, v.repo, v.wf, v.job) + token := GenerateTokenAt("key", v.org, v.repo, v.wf, v.job, ts) if token == base { t.Errorf("token for %s should differ from base", v.name) } @@ -39,40 +54,127 @@ func TestGenerateScopedToken_ScopePinning(t *testing.T) { } } -func TestGenerateScopedToken_DifferentKeys(t *testing.T) { - token1 := GenerateScopedToken("key-a", "org", "repo", "wf", "job") - token2 := GenerateScopedToken("key-b", "org", "repo", "wf", "job") +func TestGenerateTokenAt_DifferentKeys(t *testing.T) { + ts := time.Unix(1700000000, 0) + token1 := GenerateTokenAt("key-a", "org", "repo", "wf", "job", ts) + token2 := GenerateTokenAt("key-b", "org", "repo", "wf", "job", ts) if token1 == token2 { t.Error("different keys should produce different tokens") } } -func TestGenerateScopedToken_ValidHex(t *testing.T) { - token := GenerateScopedToken("key", "org", "repo", "wf", "job") - if len(token) != 64 { - t.Errorf("token length = %d, want 64", len(token)) - } - if _, err := hex.DecodeString(token); err != nil { - t.Errorf("token is not valid hex: %v", err) +func TestGenerateTokenAt_DifferentTimestamps(t *testing.T) { + ts1 := time.Unix(1700000000, 0) + ts2 := time.Unix(1700000001, 0) + token1 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts1) + token2 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts2) + if token1 == token2 { + t.Error("different timestamps should produce different tokens") } } -func TestValidateScopedToken_Correct(t *testing.T) { - token := GenerateScopedToken("key", "org", "repo", "wf", "job") - if !ValidateScopedToken("key", token, "org", "repo", "wf", "job") { - t.Error("ValidateScopedToken should accept correct token") +func TestValidateToken_Correct(t *testing.T) { + ts := time.Now() + token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) + if !ValidateToken("key", token, "org", "repo", "wf", "job", 5*time.Minute) { + t.Error("ValidateToken should accept correct token") } } -func TestValidateScopedToken_WrongToken(t *testing.T) { - if ValidateScopedToken("key", "deadbeef", "org", "repo", "wf", "job") { - t.Error("ValidateScopedToken should reject wrong token") +func TestValidateToken_WrongToken(t *testing.T) { + if ValidateToken("key", "12345:deadbeef", "org", "repo", "wf", "job", 5*time.Minute) { + t.Error("ValidateToken should reject wrong token") } } -func TestValidateScopedToken_WrongScope(t *testing.T) { - token := GenerateScopedToken("key", "org", "repo", "wf", "job") - if ValidateScopedToken("key", token, "org", "repo", "wf", "other-job") { - t.Error("ValidateScopedToken should reject token for different scope") +func TestValidateToken_WrongScope(t *testing.T) { + ts := time.Now() + token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) + if ValidateToken("key", token, "org", "repo", "wf", "other-job", 5*time.Minute) { + t.Error("ValidateToken should reject token for different scope") + } +} + +func TestValidateToken_Expired(t *testing.T) { + ts := time.Now().Add(-10 * time.Minute) + token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) + if ValidateToken("key", token, "org", "repo", "wf", "job", 5*time.Minute) { + t.Error("ValidateToken should reject expired token") + } +} + +func TestValidateTokenAt_NotExpired(t *testing.T) { + tokenTime := time.Unix(1700000000, 0) + token := GenerateTokenAt("key", "org", "repo", "wf", "job", tokenTime) + + // Validate at 4 minutes later (within 5 minute TTL) + now := tokenTime.Add(4 * time.Minute) + if !ValidateTokenAt("key", token, "org", "repo", "wf", "job", 5*time.Minute, now) { + t.Error("ValidateTokenAt should accept token within TTL") + } +} + +func TestValidateTokenAt_JustExpired(t *testing.T) { + tokenTime := time.Unix(1700000000, 0) + token := GenerateTokenAt("key", "org", "repo", "wf", "job", tokenTime) + + // Validate at 6 minutes later (beyond 5 minute TTL) + now := tokenTime.Add(6 * time.Minute) + if ValidateTokenAt("key", token, "org", "repo", "wf", "job", 5*time.Minute, now) { + t.Error("ValidateTokenAt should reject token beyond TTL") + } +} + +func TestValidateToken_InvalidFormat(t *testing.T) { + if ValidateToken("key", "no-colon-here", "org", "repo", "wf", "job", 5*time.Minute) { + t.Error("ValidateToken should reject token without colon") + } + if ValidateToken("key", "not-a-number:abc123", "org", "repo", "wf", "job", 5*time.Minute) { + t.Error("ValidateToken should reject token with invalid timestamp") + } +} + +func TestParseTokenTimestamp(t *testing.T) { + ts := time.Unix(1700000000, 0) + token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) + + parsed, err := ParseTokenTimestamp(token) + if err != nil { + t.Fatalf("ParseTokenTimestamp failed: %v", err) + } + if !parsed.Equal(ts) { + t.Errorf("parsed timestamp = %v, want %v", parsed, ts) + } +} + +func TestParseTokenTimestamp_Invalid(t *testing.T) { + _, err := ParseTokenTimestamp("no-colon") + if err == nil { + t.Error("ParseTokenTimestamp should fail on missing colon") + } + + _, err = ParseTokenTimestamp("not-a-number:abc123") + if err == nil { + t.Error("ParseTokenTimestamp should fail on invalid timestamp") + } +} + +func TestValidateToken_TamperedTimestamp(t *testing.T) { + // Generate a valid token + ts := time.Now() + token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts) + + parts := strings.SplitN(token, ":", 2) + if len(parts) != 2 { + t.Fatalf("unexpected token format: %q", token) + } + hmacPart := parts[1] + + // Tamper with timestamp (e.g., attacker tries to extend token lifetime) + tamperedTimestamp := strconv.FormatInt(time.Now().Add(1*time.Hour).Unix(), 10) + tamperedToken := tamperedTimestamp + ":" + hmacPart + + if ValidateToken("key", tamperedToken, "org", "repo", "wf", "job", 5*time.Minute) { + t.Error("ValidateToken should reject token with tampered timestamp") } }