mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
security: bcrypt admin auth, JWKS per-kid cache, gRPC reflection dev-only, audit bounded+rand IDs, credential hashing
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user