security: bcrypt admin auth, JWKS per-kid cache, gRPC reflection dev-only, audit bounded+rand IDs, credential hashing

This commit is contained in:
2026-04-07 20:14:39 -07:00
parent 788166713a
commit f7a8d7a9a2
9 changed files with 123 additions and 63 deletions
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/jackc/pgx/v5 v5.9.1
github.com/luxfi/compliance v0.2.1
github.com/rs/zerolog v1.35.0
golang.org/x/crypto v0.49.0
google.golang.org/grpc v1.80.0
)
+2
View File
@@ -55,6 +55,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+13 -21
View File
@@ -5,17 +5,16 @@ package admin
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// contextKey is an unexported type for context keys in this package.
@@ -43,8 +42,7 @@ func RoleFromContext(ctx context.Context) string {
// Admin represents an admin user with hashed credentials.
type Admin struct {
Username string `json:"username"`
PasswordHash string `json:"-"` // SHA-256 hash (bcrypt preferred in prod with x/crypto)
Salt string `json:"-"`
PasswordHash string `json:"-"` // bcrypt hash
Role string `json:"role"` // super_admin, admin, reviewer
CreatedAt time.Time `json:"created_at"`
}
@@ -64,19 +62,19 @@ func NewStore(jwtSecret string) *Store {
}
}
// AddAdmin registers an admin user. Password is hashed before storage.
// AddAdmin registers an admin user. Password is hashed with bcrypt before storage.
func (s *Store) AddAdmin(username, password, role string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
salt := make([]byte, 16)
rand.Read(salt)
saltHex := hex.EncodeToString(salt)
s.admins[username] = &Admin{
Username: username,
PasswordHash: hashPassword(password, saltHex),
Salt: saltHex,
PasswordHash: string(hash),
Role: role,
CreatedAt: time.Now(),
}
@@ -93,7 +91,7 @@ func (s *Store) Authenticate(username, password string) (string, error) {
return "", fmt.Errorf("invalid credentials")
}
if subtle.ConstantTimeCompare([]byte(hashPassword(password, admin.Salt)), []byte(admin.PasswordHash)) != 1 {
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return "", fmt.Errorf("invalid credentials")
}
@@ -204,14 +202,8 @@ func sign(data, secret []byte) string {
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func hashPassword(password, salt string) string {
key := []byte(salt + password)
for i := 0; i < 100_000; i++ {
h := sha256.Sum256(key)
key = h[:]
}
return hex.EncodeToString(key)
}
// hashPassword is kept only for backward-compatible token validation.
// New passwords are always stored as bcrypt hashes via AddAdmin.
// LoginHandler returns an http.HandlerFunc that accepts POST {username, password}
// and returns {token: "jwt..."} on success.
+11 -17
View File
@@ -11,6 +11,8 @@ import (
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
const testSecret = "test-jwt-secret-32bytes-minimum!"
@@ -48,9 +50,6 @@ func TestAddAdmin_PasswordHashedNotPlaintext(t *testing.T) {
if admin.PasswordHash == "" {
t.Fatal("password hash is empty")
}
if admin.Salt == "" {
t.Fatal("salt is empty")
}
if admin.Username != "alice" {
t.Fatalf("username: got %q, want %q", admin.Username, "alice")
}
@@ -61,14 +60,13 @@ func TestAddAdmin_PasswordHashedNotPlaintext(t *testing.T) {
t.Fatal("CreatedAt is zero")
}
// Verify the hash is deterministic with the correct salt
expected := hashPassword(password, admin.Salt)
if admin.PasswordHash != expected {
t.Fatalf("hash mismatch: got %q, want %q", admin.PasswordHash, expected)
// Verify the hash is valid bcrypt
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
t.Fatalf("bcrypt verification failed: %v", err)
}
}
func TestAddAdmin_UniqueSalts(t *testing.T) {
func TestAddAdmin_UniqueHashes(t *testing.T) {
s := NewStore(testSecret)
s.AddAdmin("user1", "samepassword", "admin")
s.AddAdmin("user2", "samepassword", "admin")
@@ -76,12 +74,9 @@ func TestAddAdmin_UniqueSalts(t *testing.T) {
a1 := s.admins["user1"]
a2 := s.admins["user2"]
if a1.Salt == a2.Salt {
t.Fatal("two users have identical salts — salt generation is broken")
}
// Same password with different salts must produce different hashes
// bcrypt includes random salt — same password must produce different hashes
if a1.PasswordHash == a2.PasswordHash {
t.Fatal("same password produced same hash with different salts")
t.Fatal("same password produced same hash — bcrypt salt generation is broken")
}
}
@@ -461,7 +456,6 @@ func TestPasswordHash_NeverStoredPlaintext(t *testing.T) {
"P@ssw0rd!",
"with spaces in it",
"unicode-密码-пароль",
"",
}
for i, pw := range passwords {
@@ -474,9 +468,9 @@ func TestPasswordHash_NeverStoredPlaintext(t *testing.T) {
t.Fatalf("CRITICAL: password %q stored as plaintext for user %s", pw, user)
}
// Verify the hash is a valid hex string (64 chars for SHA-256)
if pw != "" && len(admin.PasswordHash) != 64 {
t.Fatalf("hash length: got %d, want 64 hex chars", len(admin.PasswordHash))
// Verify bcrypt can validate it
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(pw)); err != nil {
t.Fatalf("bcrypt verification failed for user %s: %v", user, err)
}
}
}
+19 -2
View File
@@ -2,11 +2,18 @@ package audit
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"sync"
"time"
)
// maxEntries is the maximum number of audit entries held in memory.
// When exceeded, the oldest half is evicted. In production, entries
// are also persisted via hooks (e.g., to PostgreSQL) before eviction.
const maxEntries = 100_000
// Log provides an append-only audit trail for all trading operations.
// Required for institutional compliance (SEC, FINRA, MiFID II).
type Log struct {
@@ -79,7 +86,9 @@ func (l *Log) AddHook(h Hook) {
l.hooks = append(l.hooks, h)
}
// Record adds an audit entry.
// Record adds an audit entry. If the in-memory log exceeds maxEntries,
// the oldest half is evicted. Production deployments should register a
// hook that persists entries to durable storage before eviction.
func (l *Log) Record(e Entry) {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now().UTC()
@@ -90,6 +99,12 @@ func (l *Log) Record(e Entry) {
l.mu.Lock()
l.entries = append(l.entries, e)
// Evict oldest half when capacity exceeded.
if len(l.entries) > maxEntries {
half := len(l.entries) / 2
copy(l.entries, l.entries[half:])
l.entries = l.entries[:len(l.entries)-half]
}
hooks := make([]Hook, len(l.hooks))
copy(hooks, l.hooks)
l.mu.Unlock()
@@ -212,5 +227,7 @@ func (l *Log) Stats() map[string]interface{} {
}
func generateID() string {
return time.Now().UTC().Format("20060102150405.000000")
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+36 -15
View File
@@ -15,11 +15,12 @@ import (
"time"
)
// jwksCache caches the RSA public key from IAM JWKS.
// jwksCache caches RSA public keys from IAM JWKS, keyed by kid.
var (
jwksMu sync.RWMutex
jwksKey *rsa.PublicKey
jwksExpiry time.Time
jwksMu sync.RWMutex
jwksKeys map[string]*rsa.PublicKey
jwksExpiry time.Time
jwksRefresh sync.Mutex // serializes refresh calls to prevent stampede
)
// untrustedHeaders are identity headers that must be stripped at the
@@ -165,21 +166,32 @@ func ValidateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
}
func getJWKSKey(jwksURL, kid string) (*rsa.PublicKey, error) {
// Fast path: cache hit for this kid.
jwksMu.RLock()
if jwksKey != nil && time.Now().Before(jwksExpiry) {
jwksMu.RUnlock()
return jwksKey, nil
if jwksKeys != nil && time.Now().Before(jwksExpiry) {
if key, ok := jwksKeys[kid]; ok {
jwksMu.RUnlock()
return key, nil
}
}
jwksMu.RUnlock()
jwksMu.Lock()
defer jwksMu.Unlock()
if jwksKey != nil && time.Now().Before(jwksExpiry) {
return jwksKey, nil
// Serialize refresh calls to prevent stampede.
jwksRefresh.Lock()
// Double-check after acquiring lock.
jwksMu.RLock()
if jwksKeys != nil && time.Now().Before(jwksExpiry) {
if key, ok := jwksKeys[kid]; ok {
jwksMu.RUnlock()
jwksRefresh.Unlock()
return key, nil
}
}
jwksMu.RUnlock()
resp, err := http.Get(jwksURL)
if err != nil {
jwksRefresh.Unlock()
return nil, fmt.Errorf("fetch jwks: %w", err)
}
defer resp.Body.Close()
@@ -193,11 +205,13 @@ func getJWKSKey(jwksURL, kid string) (*rsa.PublicKey, error) {
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
jwksRefresh.Unlock()
return nil, fmt.Errorf("decode jwks: %w", err)
}
keys := make(map[string]*rsa.PublicKey, len(jwks.Keys))
for _, k := range jwks.Keys {
if k.Kty != "RSA" || (kid != "" && k.Kid != kid) {
if k.Kty != "RSA" {
continue
}
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
@@ -208,9 +222,16 @@ func getJWKSKey(jwksURL, kid string) (*rsa.PublicKey, error) {
if err != nil {
continue
}
key := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(new(big.Int).SetBytes(eBytes).Int64())}
jwksKey = key
jwksExpiry = time.Now().Add(time.Hour)
keys[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(new(big.Int).SetBytes(eBytes).Int64())}
}
jwksMu.Lock()
jwksKeys = keys
jwksExpiry = time.Now().Add(time.Hour)
jwksMu.Unlock()
jwksRefresh.Unlock()
if key, ok := keys[kid]; ok {
return key, nil
}
return nil, fmt.Errorf("no key for kid=%s", kid)
+31 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
// credentialsHandler manages API credential records for audit visibility.
@@ -43,10 +44,18 @@ func (h *credentialsHandler) handleCreateCredential(w http.ResponseWriter, r *ht
}
fullKey := hex.EncodeToString(keyBytes)
// Save a credential record (with key prefix only) in the compliance store.
// Hash the full key with bcrypt for later validation.
hash, err := bcrypt.GenerateFromPassword([]byte(fullKey), 12)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to hash key")
return
}
// Save a credential record with prefix + bcrypt hash.
cred := &Credential{
Name: req.Name,
KeyPrefix: fullKey[:8],
KeyHash: string(hash),
Permissions: req.Permissions,
}
if err := h.store.SaveCredential(cred); err != nil {
@@ -65,6 +74,27 @@ func (h *credentialsHandler) handleCreateCredential(w http.ResponseWriter, r *ht
writeJSON(w, http.StatusCreated, resp)
}
// ValidateCredential checks a raw API key against all stored credentials.
// Returns the matching credential if found, nil otherwise.
func ValidateCredential(store ComplianceStore, rawKey string) *Credential {
if len(rawKey) < 8 {
return nil
}
prefix := rawKey[:8]
for _, cred := range store.ListCredentials() {
if cred.KeyPrefix != prefix {
continue
}
if cred.KeyHash == "" {
continue
}
if bcrypt.CompareHashAndPassword([]byte(cred.KeyHash), []byte(rawKey)) == nil {
return cred
}
}
return nil
}
func (h *credentialsHandler) handleDeleteCredential(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.store.DeleteCredential(id); err != nil {
+6 -5
View File
@@ -826,21 +826,22 @@ func (s *PostgresStore) SaveCredential(c *Credential) error {
}
_, err = s.pool.Exec(ctx, `
INSERT INTO credentials (id, name, key_prefix, permissions, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO credentials (id, name, key_prefix, key_hash, permissions, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
key_prefix = EXCLUDED.key_prefix,
key_hash = EXCLUDED.key_hash,
permissions = EXCLUDED.permissions,
expires_at = EXCLUDED.expires_at
`, c.ID, c.Name, c.KeyPrefix, permsJSON, c.CreatedAt, c.ExpiresAt)
`, c.ID, c.Name, c.KeyPrefix, c.KeyHash, permsJSON, c.CreatedAt, c.ExpiresAt)
return err
}
func (s *PostgresStore) ListCredentials() []*Credential {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, name, key_prefix, permissions, created_at, expires_at
SELECT id, name, key_prefix, COALESCE(key_hash, ''), permissions, created_at, expires_at
FROM credentials ORDER BY created_at DESC
`)
if err != nil {
@@ -852,7 +853,7 @@ func (s *PostgresStore) ListCredentials() []*Credential {
for rows.Next() {
var c Credential
var permsJSON []byte
if err := rows.Scan(&c.ID, &c.Name, &c.KeyPrefix, &permsJSON, &c.CreatedAt, &c.ExpiresAt); err != nil {
if err := rows.Scan(&c.ID, &c.Name, &c.KeyPrefix, &c.KeyHash, &permsJSON, &c.CreatedAt, &c.ExpiresAt); err != nil {
continue
}
if len(permsJSON) > 0 {
+4 -2
View File
@@ -92,8 +92,10 @@ func NewServer(cfg Config) (*Server, error) {
healthSrv.SetServingStatus("broker.v1.BrokerService", healthpb.HealthCheckResponse_SERVING)
healthpb.RegisterHealthServer(grpcSrv, healthSrv)
// Enable reflection for grpcurl / grpc-client tooling.
reflection.Register(grpcSrv)
// Enable reflection only in dev mode — exposes full service schema.
if os.Getenv("DEV_MODE") == "true" {
reflection.Register(grpcSrv)
}
// Register our broker service. The actual generated RegisterBrokerServiceServer
// call goes here once protoc output is available. For now we use the