From 7d8ee5612e9fdfe2a77d5217e3ce007d4edb23f5 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Fri, 31 Jul 2026 22:31:29 +0100 Subject: [PATCH 1/6] feat: API Key Validation Middleware --- services/api/middleware/auth.go | 35 ++++++--- services/api/middleware/auth_test.go | 111 ++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 13 deletions(-) diff --git a/services/api/middleware/auth.go b/services/api/middleware/auth.go index ce17749..7857060 100644 --- a/services/api/middleware/auth.go +++ b/services/api/middleware/auth.go @@ -4,6 +4,7 @@ import ( "context" "crypto/hmac" "crypto/sha256" + "crypto/subtle" "encoding/hex" "fmt" "net/http" @@ -29,9 +30,12 @@ type DBAuthConfig struct { const authCacheTTL = 5 * time.Minute // ParseKeyHashes parses a comma-separated list of HMAC-SHA256 hex digests -// (as stored in API_KEY_HASHES) into a set for O(1) lookup. +// (as stored in API_KEY_HASHES or API_KEY) into a set for lookup. func ParseKeyHashes(raw string) map[string]struct{} { out := map[string]struct{}{} + if raw == "" { + raw = os.Getenv("API_KEY") + } for _, h := range strings.Split(raw, ",") { h = strings.TrimSpace(h) if h != "" { @@ -41,8 +45,20 @@ func ParseKeyHashes(raw string) map[string]struct{} { return out } +// ConstantTimeContains checks whether target matches any hash in validHashes in +// constant time using crypto/subtle.ConstantTimeCompare to avoid timing side-channel attacks. +func ConstantTimeContains(validHashes map[string]struct{}, target string) bool { + var match int + for hash := range validHashes { + if len(hash) == len(target) { + match |= subtle.ConstantTimeCompare([]byte(hash), []byte(target)) + } + } + return match == 1 +} + // hmacKeyHash computes HMAC-SHA256 of key using API_KEY_SALT — used for the -// legacy API_KEY_HASHES env-var authentication path. +// legacy API_KEY_HASHES / API_KEY env-var authentication path. func hmacKeyHash(key string) string { salt := []byte(os.Getenv("API_KEY_SALT")) mac := hmac.New(sha256.New, salt) @@ -70,7 +86,7 @@ func authRedisCacheKey(hash string) string { // NewDBAuth returns an authentication middleware that: // 1. Looks up the hashed API key in Redis cache (5 min TTL). // 2. Falls back to the api_keys database table (active keys only). -// 3. Falls back to legacy HMAC-SHA256 env-var authentication (API_KEY_HASHES). +// 3. Falls back to legacy HMAC-SHA256 env-var authentication (API_KEY_HASHES / API_KEY). // // On success, api_key_id and network are attached to the request context. // Unauthenticated requests receive 401 unless the path is excluded. @@ -129,10 +145,10 @@ func NewDBAuth(cfg DBAuthConfig) func(http.Handler) http.Handler { } } - // ── 3. Legacy env-var fallback (API_KEY_HASHES) ──────────────── + // ── 3. Legacy env-var fallback (API_KEY_HASHES / API_KEY) ────── validHashes := ParseKeyHashes(os.Getenv("API_KEY_HASHES")) if len(validHashes) > 0 { - if _, ok := validHashes[hmacKeyHash(key)]; ok { + if ConstantTimeContains(validHashes, hmacKeyHash(key)) { next.ServeHTTP(w, r) return } @@ -144,12 +160,11 @@ func NewDBAuth(cfg DBAuthConfig) func(http.Handler) http.Handler { } // Validator returns a func(string) bool that checks whether the HMAC-SHA256 -// of the provided key is in the given valid hashes set. Used by the GraphQL -// WebSocket handler which needs a standalone key-check function. +// of the provided key is in the given valid hashes set in constant time. Used +// by the GraphQL WebSocket handler which needs a standalone key-check function. func Validator(hashes map[string]struct{}) func(string) bool { return func(key string) bool { - _, ok := hashes[hmacKeyHash(key)] - return ok + return ConstantTimeContains(hashes, hmacKeyHash(key)) } } @@ -180,7 +195,7 @@ func Auth(validHashes map[string]struct{}, next http.Handler) http.Handler { return } - if _, ok := validHashes[hmacKeyHash(key)]; !ok { + if !ConstantTimeContains(validHashes, hmacKeyHash(key)) { httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, "Unauthorized") return } diff --git a/services/api/middleware/auth_test.go b/services/api/middleware/auth_test.go index eaea58c..d6cb95f 100644 --- a/services/api/middleware/auth_test.go +++ b/services/api/middleware/auth_test.go @@ -4,10 +4,13 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/Depo-dev/trident/services/api/internal/httputil" "github.com/Depo-dev/trident/services/api/middleware" ) @@ -20,7 +23,7 @@ func hashKey(salt, key string) string { func TestAPIKey(t *testing.T) { const ( salt = "test-salt" - key = "valid-key" + key = "valid-key-32-byte-hex-string-format" ) t.Setenv("API_KEY_SALT", salt) t.Setenv("API_KEY_HASHES", hashKey(salt, key)) @@ -34,10 +37,11 @@ func TestAPIKey(t *testing.T) { path string key string wantStatus int + checkBody bool }{ {name: "valid protected request", path: "/v1/events/stream", key: key, wantStatus: http.StatusNoContent}, - {name: "missing key", path: "/v1/events/stream", wantStatus: http.StatusUnauthorized}, - {name: "invalid key", path: "/v1/events/stream", key: "wrong", wantStatus: http.StatusUnauthorized}, + {name: "missing key", path: "/v1/events/stream", wantStatus: http.StatusUnauthorized, checkBody: true}, + {name: "invalid key", path: "/v1/events/stream", key: "wrong-key", wantStatus: http.StatusUnauthorized, checkBody: true}, {name: "health is public", path: "/v1/health", wantStatus: http.StatusNoContent}, } @@ -54,6 +58,107 @@ func TestAPIKey(t *testing.T) { if rec.Code != tt.wantStatus { t.Fatalf("status: got %d, want %d", rec.Code, tt.wantStatus) } + + if tt.checkBody { + var errResp httputil.ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("failed to decode error body: %v", err) + } + if errResp.Error.Code != httputil.UNAUTHORIZED { + t.Errorf("error code: got %q, want %q", errResp.Error.Code, httputil.UNAUTHORIZED) + } + } }) } } + +func TestSingleAPIKeyEnv(t *testing.T) { + const ( + salt = "test-salt" + key = "single-env-key-value" + ) + t.Setenv("API_KEY_SALT", salt) + t.Setenv("API_KEY_HASHES", "") + t.Setenv("API_KEY", hashKey(salt, key)) + + handler := middleware.APIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/v1/events", nil) + req.Header.Set("X-API-Key", key) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestConstantTimeContains(t *testing.T) { + hashes := map[string]struct{}{ + "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90": {}, + "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff": {}, + } + + tests := []struct { + name string + target string + expected bool + }{ + { + name: "exact match first", + target: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + expected: true, + }, + { + name: "exact match second", + target: "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff", + expected: true, + }, + { + name: "same length non match", + target: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f99", + expected: false, + }, + { + name: "different length", + target: "a1b2c3d4e5f60718293a4b5c6d7e8f90", + expected: false, + }, + { + name: "empty string", + target: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := middleware.ConstantTimeContains(hashes, tt.target) + if got != tt.expected { + t.Errorf("ConstantTimeContains(%q) = %v, want %v", tt.target, got, tt.expected) + } + }) + } +} + +func TestNoRawKeyLeakage(t *testing.T) { + // Verify that ParseKeyHashes stores hashes and not raw values + raw := "hash1,hash2" + hashes := middleware.ParseKeyHashes(raw) + + if _, ok := hashes["hash1"]; !ok { + t.Error("expected hash1 in parsed key hashes") + } + if _, ok := hashes["hash2"]; !ok { + t.Error("expected hash2 in parsed key hashes") + } + for k := range hashes { + if strings.Contains(k, "raw-key-value") { + t.Errorf("found raw key in hashes: %s", k) + } + } +} + From 659d08ab152b2d46f78849ad6a7a97d11788a3af Mon Sep 17 00:00:00 2001 From: DioChuks Date: Fri, 31 Jul 2026 22:31:55 +0100 Subject: [PATCH 2/6] feat: Key Generation Tooling --- cmd/keygen/main.go | 47 +++++++++++++++++++++++++++++++++++++ scripts/generate-api-key.sh | 10 ++++++++ 2 files changed, 57 insertions(+) create mode 100644 cmd/keygen/main.go create mode 100644 scripts/generate-api-key.sh diff --git a/cmd/keygen/main.go b/cmd/keygen/main.go new file mode 100644 index 0000000..947dcbc --- /dev/null +++ b/cmd/keygen/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "os" +) + +func main() { + saltFlag := flag.String("salt", "", "API_KEY_SALT deployment secret (defaults to API_KEY_SALT env var)") + flag.Parse() + + salt := *saltFlag + if salt == "" { + salt = os.Getenv("API_KEY_SALT") + } + + if salt == "" { + fmt.Fprintln(os.Stderr, "Warning: API_KEY_SALT is empty. Using default development salt.") + salt = "default-salt" + } + + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + fmt.Fprintf(os.Stderr, "Error generating random key: %v\n", err) + os.Exit(1) + } + + rawKey := hex.EncodeToString(keyBytes) + + mac := hmac.New(sha256.New, []byte(salt)) + mac.Write([]byte(rawKey)) + keyHash := hex.EncodeToString(mac.Sum(nil)) + + fmt.Println("=== Trident API Key Generator ===") + fmt.Printf("Raw API Key (client X-API-Key): %s\n", rawKey) + fmt.Printf("HMAC-SHA256 Hash (server config): %s\n", keyHash) + fmt.Printf("Salt used: %s\n\n", salt) + fmt.Println("Configuration instructions:") + fmt.Printf(" API_KEY_SALT=%s\n", salt) + fmt.Printf(" API_KEY_HASHES=%s\n", keyHash) + fmt.Println(" (or API_KEY=" + keyHash + ")") +} diff --git a/scripts/generate-api-key.sh b/scripts/generate-api-key.sh new file mode 100644 index 0000000..bdcb7ea --- /dev/null +++ b/scripts/generate-api-key.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Utility script to generate a cryptographically random 32-byte API key +# and compute its HMAC-SHA256 hash using API_KEY_SALT. +set -euo pipefail + +cd "$(dirname "$0")/.." + +SALT="${API_KEY_SALT:-${1:-}}" + +(cd services/api && go run ./cmd/keygen ${SALT:+-salt "$SALT"}) From d864027e1cf8b9b1949802474a8e58b5abf272ff Mon Sep 17 00:00:00 2001 From: DioChuks Date: Fri, 31 Jul 2026 22:32:16 +0100 Subject: [PATCH 3/6] doc: Documentation & Environment References --- .env.example | 10 ++++-- docs/ENVIRONMENT.md | 1 + docs/api-keys.md | 86 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 docs/api-keys.md diff --git a/.env.example b/.env.example index c56c182..52937a9 100644 --- a/.env.example +++ b/.env.example @@ -62,9 +62,14 @@ PGBOUNCER_ADMIN_URL=postgres://trident:password@localhost:6432/pgbouncer ADMIN_API_KEY= # OPTIONAL go-api -# Comma-separated HMAC-SHA256 hashes of accepted API keys, using API_KEY_SALT. +# Comma-separated list of accepted HMAC-SHA256 API key digests (using API_KEY_SALT). +# Generate keys and hashes with: go run ./services/api/cmd/keygen -salt API_KEY_HASHES= +# OPTIONAL go-api +# Single accepted HMAC-SHA256 API key digest (alternative to API_KEY_HASHES for single-key deployments). +API_KEY= + # ----------------------------------------------------------------------------- # Rust indexer (crates/indexer) # ----------------------------------------------------------------------------- @@ -241,7 +246,8 @@ PORT=3000 # OPTIONAL go-api # Random secret used to salt API key hashes. Change before deployment. -# Generate with: openssl rand -hex 32 +# Generate salt with: openssl rand -hex 32 +# Generate key pair (raw key + hash) with: go run ./services/api/cmd/keygen -salt API_KEY_SALT=change-this-to-a-random-string # REQUIRED in production go-api diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 9234984..fc7347e 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -112,6 +112,7 @@ description is accurate. Keep this file honest by hand. | `ADMIN_API_KEY` | Optional | empty (admin endpoints disabled) | Shared secret for `X-Admin-Key`, gating `/v1/admin/*`. | | `INTERNAL_API_KEY` | Required to use `/internal/status` (fails closed) | empty | Shared secret for `X-Internal-Key`, gating `GET /internal/status` (issue #316). **Unset means the endpoint rejects every request** — never treat empty as "no auth needed". Compared with `crypto/subtle.ConstantTimeCompare`. | | `API_KEY_HASHES` | Optional | empty | Comma-separated HMAC-SHA256 hashes of accepted API keys, salted with `API_KEY_SALT`. | +| `API_KEY` | Optional | empty | Single accepted HMAC-SHA256 API key hash, salted with `API_KEY_SALT`. | | `API_KEY_SALT` | Optional but should be changed | `change-this-to-a-random-string` | Salt for API key hashing. | | `ALLOWED_ORIGINS` | Required in production | — (dev mode allows any origin) | Comma-separated CORS allow-list (`https://` origins, or `http://localhost*`). | | `REQUEST_TIMEOUT_MS` | Optional | `30000` | Per-request timeout middleware; excludes `/ws` and `/v1/events/stream`. | diff --git a/docs/api-keys.md b/docs/api-keys.md new file mode 100644 index 0000000..ffa6efc --- /dev/null +++ b/docs/api-keys.md @@ -0,0 +1,86 @@ +# API Key Management & Security Model + +This document describes the API key lifecycle in Trident: key generation, configuration, runtime validation, and zero-downtime rotation. + +## Overview & Security Model + +Trident validates incoming client API keys via the `X-API-Key` HTTP request header on protected endpoints. + +### Key Security Principles + +1. **Cryptographic Generation**: API keys are generated as 32 cryptographically random bytes, hex-encoded into 64-character strings. +2. **No Raw Key Storage**: The Go REST API process **never** stores or logs raw API keys in memory after startup. Only HMAC-SHA256 digests (calculated with `API_KEY_SALT`) or SHA-256 hashes (for database-managed keys) are retained or evaluated. +3. **Timing-Attack Resistance**: Verification uses `crypto/subtle.ConstantTimeCompare` across candidate valid key hashes to ensure constant-time comparison regardless of key correctness or position. + +--- + +## Key Generation + +Use `cmd/keygen` or the helper script `scripts/generate-api-key.sh` to generate a new valid API key pair: + +```bash +# Using the helper script: +API_KEY_SALT="your-deployment-salt" ./scripts/generate-api-key.sh + +# Or using go run directly: +go run ./services/api/cmd/keygen -salt "your-deployment-salt" +``` + +### Example Output + +``` +=== Trident API Key Generator === +Raw API Key (client X-API-Key): 8f3a9b... (64 hex characters) +HMAC-SHA256 Hash (server config): c4e17... (64 hex characters) +Salt used: your-deployment-salt + +Configuration instructions: + API_KEY_SALT=your-deployment-salt + API_KEY_HASHES=c4e17... +``` + +- **Client**: Pass the **Raw API Key** in the `X-API-Key` HTTP header. +- **Server**: Configure **`API_KEY_SALT`** and **`API_KEY_HASHES`** (or `API_KEY`) in your environment configuration. + +--- + +## Environment Configuration + +In your deployment environment (`.env`, Helm values, or Fly secrets): + +```env +# Deployment secret for salting API key hashes +API_KEY_SALT=your-deployment-salt + +# Comma-separated list of accepted HMAC-SHA256 key digests +API_KEY_HASHES=hash_1,hash_2 + +# Single key digest alternative (Phase 1 convenience) +API_KEY=hash_1 +``` + +--- + +## Zero-Downtime Key Rotation + +To rotate an existing API key or revoke a compromised key without downtime: + +1. **Generate New Key Pair**: Run `cmd/keygen` using the current `API_KEY_SALT` to obtain a new raw key and its HMAC hash (`hash_new`). +2. **Update Server Environment**: Set `API_KEY_HASHES` to include both the active old hash and the new hash: + ```env + API_KEY_HASHES=hash_old,hash_new + ``` + Deploy the server. The Go API now accepts requests signed by either key. +3. **Update Clients**: Transition client applications to send the new raw key in `X-API-Key`. +4. **Decommission Old Key**: Remove `hash_old` from `API_KEY_HASHES`: + ```env + API_KEY_HASHES=hash_new + ``` + Redeploy the server to finalize rotation. + +--- + +## Phase 1 vs Phase 2 Architecture + +- **Phase 1 (Environment Variables)**: Static single or multi-key authentication via `API_KEY` / `API_KEY_HASHES` environment variables. Ideal for standalone or single-tenant deployments. +- **Phase 2 (Database-backed `api_keys`)**: Multi-tenant API key management via Postgres (`api_keys` table), rate-limiting tiers (Free, Pro, Internal), dynamic creation (`POST /v1/api-keys`), revocation (`DELETE /v1/api-keys/{id}`), and Redis caching. From be28ff4eceaf9ad26a9cedeb68402b4bfd611af5 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Fri, 31 Jul 2026 22:33:09 +0100 Subject: [PATCH 4/6] test: unit tests --- services/api/handlers/status_test.go | 57 ++-------------------------- 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/services/api/handlers/status_test.go b/services/api/handlers/status_test.go index 69ae5c5..a84afce 100644 --- a/services/api/handlers/status_test.go +++ b/services/api/handlers/status_test.go @@ -1,5 +1,4 @@ package handlers_test -package handlers import ( "net/http" @@ -63,9 +62,7 @@ func TestInternalStatus_ValidKey_Returns200(t *testing.T) { // TestInternalStatus_NoRawKeyLeakage sends a distinctive, known // X-Internal-Key value (both a wrong one on a 401 response and the correct // one on a 200 response) and asserts the raw key string never appears -// anywhere in the response body. The internal key is only ever compared via -// validAdminKey's constant-time check and never echoed, logged, or embedded -// in an error message, so this should hold for both outcomes. +// anywhere in the response body. func TestInternalStatus_NoRawKeyLeakage(t *testing.T) { const rawInternalKey = "trident-internal-status-super-secret-value" t.Setenv("INTERNAL_API_KEY", rawInternalKey) @@ -97,53 +94,6 @@ func TestInternalStatus_NoRawKeyLeakage(t *testing.T) { t.Errorf("raw internal key leaked into 200 response body: %q", rr.Body.String()) } }) - "testing" -) - -// These tests exercise the X-Internal-Key check on GET /internal/status -// directly (package handlers, not handlers_test) since statusDeps is -// unexported. statusDeps is left nil throughout, which is fine: the auth -// check runs before any dependency is touched. - -func TestInternalStatus_CorrectKey_Returns200(t *testing.T) { - t.Setenv("INTERNAL_API_KEY", "correct-horse-battery-staple") - - req := httptest.NewRequest(http.MethodGet, "/internal/status", nil) - req.Header.Set("X-Internal-Key", "correct-horse-battery-staple") - rr := httptest.NewRecorder() - - InternalStatus()(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) - } -} - -func TestInternalStatus_WrongKey_Returns401(t *testing.T) { - t.Setenv("INTERNAL_API_KEY", "correct-horse-battery-staple") - - req := httptest.NewRequest(http.MethodGet, "/internal/status", nil) - req.Header.Set("X-Internal-Key", "wrong-key") - rr := httptest.NewRecorder() - - InternalStatus()(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d: %s", rr.Code, rr.Body.String()) - } -} - -func TestInternalStatus_MissingHeader_Returns401(t *testing.T) { - t.Setenv("INTERNAL_API_KEY", "correct-horse-battery-staple") - - req := httptest.NewRequest(http.MethodGet, "/internal/status", nil) - rr := httptest.NewRecorder() - - InternalStatus()(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("want 401, got %d: %s", rr.Code, rr.Body.String()) - } } func TestInternalStatus_UnsetKey_FailsClosed(t *testing.T) { @@ -152,10 +102,9 @@ func TestInternalStatus_UnsetKey_FailsClosed(t *testing.T) { t.Setenv("INTERNAL_API_KEY", "") req := httptest.NewRequest(http.MethodGet, "/internal/status", nil) - // Deliberately do not set X-Internal-Key at all. rr := httptest.NewRecorder() - InternalStatus()(rr, req) + handlers.InternalStatus()(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("want 401 (fail closed), got %d: %s", rr.Code, rr.Body.String()) @@ -171,7 +120,7 @@ func TestInternalStatus_UnsetKey_EmptyProvidedKey_StillRejected(t *testing.T) { req.Header.Set("X-Internal-Key", "") rr := httptest.NewRecorder() - InternalStatus()(rr, req) + handlers.InternalStatus()(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("want 401 (fail closed), got %d: %s", rr.Code, rr.Body.String()) From 081995e6930199b52d68a0e7782d99c4f3604145 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Fri, 31 Jul 2026 22:33:41 +0100 Subject: [PATCH 5/6] fix: missing codes.Unavailable mapping in GRPCToHTTP --- services/api/internal/httputil/errors.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/api/internal/httputil/errors.go b/services/api/internal/httputil/errors.go index 3650cd6..32ae160 100644 --- a/services/api/internal/httputil/errors.go +++ b/services/api/internal/httputil/errors.go @@ -88,6 +88,8 @@ func GRPCToHTTP(err error) (int, ErrorCode) { // The backend did not answer within the call deadline: a gateway // timeout, not an internal fault — clients may retry (issue #227). return http.StatusGatewayTimeout, UNAVAILABLE + case codes.Unavailable: + return http.StatusServiceUnavailable, UNAVAILABLE default: return http.StatusInternalServerError, INTERNAL } From ec158b48994d9a7470e9b764466162198678a0ad Mon Sep 17 00:00:00 2001 From: DioChuks Date: Fri, 31 Jul 2026 22:34:18 +0100 Subject: [PATCH 6/6] feat: add a Go CLI utility that generates a 32-byte cryptographically secure random API key --- services/api/cmd/keygen/main.go | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 services/api/cmd/keygen/main.go diff --git a/services/api/cmd/keygen/main.go b/services/api/cmd/keygen/main.go new file mode 100644 index 0000000..947dcbc --- /dev/null +++ b/services/api/cmd/keygen/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "os" +) + +func main() { + saltFlag := flag.String("salt", "", "API_KEY_SALT deployment secret (defaults to API_KEY_SALT env var)") + flag.Parse() + + salt := *saltFlag + if salt == "" { + salt = os.Getenv("API_KEY_SALT") + } + + if salt == "" { + fmt.Fprintln(os.Stderr, "Warning: API_KEY_SALT is empty. Using default development salt.") + salt = "default-salt" + } + + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + fmt.Fprintf(os.Stderr, "Error generating random key: %v\n", err) + os.Exit(1) + } + + rawKey := hex.EncodeToString(keyBytes) + + mac := hmac.New(sha256.New, []byte(salt)) + mac.Write([]byte(rawKey)) + keyHash := hex.EncodeToString(mac.Sum(nil)) + + fmt.Println("=== Trident API Key Generator ===") + fmt.Printf("Raw API Key (client X-API-Key): %s\n", rawKey) + fmt.Printf("HMAC-SHA256 Hash (server config): %s\n", keyHash) + fmt.Printf("Salt used: %s\n\n", salt) + fmt.Println("Configuration instructions:") + fmt.Printf(" API_KEY_SALT=%s\n", salt) + fmt.Printf(" API_KEY_HASHES=%s\n", keyHash) + fmt.Println(" (or API_KEY=" + keyHash + ")") +}