feat: added timestamp to HMAC to allow a TTL for the token
Some checks failed
ci / build (push) Failing after 25s

This commit is contained in:
Manuel Ganter 2026-02-13 12:48:57 +01:00
parent a96a1079eb
commit 7e3a4efb2d
No known key found for this signature in database
6 changed files with 225 additions and 58 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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() }
}

View file

@ -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<org>\x00<repo>\x00<workflow>\x00<job>".
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: "<unix_timestamp>:<hmac_hex>"
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<org>\x00<repo>\x00<workflow>\x00<job>\x00<timestamp>".
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
}

View file

@ -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")
}
}