Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,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 <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)
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -263,7 +268,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 <salt>
API_KEY_SALT=change-this-to-a-random-string

# REQUIRED in production go-api
Expand Down
47 changes: 47 additions & 0 deletions cmd/keygen/main.go
Original file line number Diff line number Diff line change
@@ -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 + ")")
}
1 change: 1 addition & 0 deletions docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
86 changes: 86 additions & 0 deletions docs/api-keys.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions scripts/generate-api-key.sh
Original file line number Diff line number Diff line change
@@ -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"})
47 changes: 47 additions & 0 deletions services/api/cmd/keygen/main.go
Original file line number Diff line number Diff line change
@@ -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 + ")")
}
4 changes: 1 addition & 3 deletions services/api/handlers/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,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)
Expand Down
35 changes: 25 additions & 10 deletions services/api/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
Expand Down Expand Up @@ -36,9 +37,12 @@ const authCacheTTL = 5 * time.Minute
const authDBQueryTimeout = 2 * time.Second

// 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 != "" {
Expand All @@ -48,8 +52,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)
Expand Down Expand Up @@ -93,7 +109,7 @@ func withAuthenticatedKey(ctx context.Context, idStr, network string) context.Co
// 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.
Expand Down Expand Up @@ -155,10 +171,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
}
Expand All @@ -170,12 +186,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))
}
}

Expand Down Expand Up @@ -206,7 +221,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
}
Expand Down
Loading
Loading