security: harden internal MPC API, enforce TLS-only transport, restrict egress

- Add bearer token auth to internal MPC API (port 9800): /keygen, /keys,
  /backup now require Authorization header. Token from MPC_INTERNAL_API_KEY
  env (KMS-synced) or derived from Ed25519 node identity.
- Replace DualModeListener with TLSOnlyListener: plaintext peer connections
  are rejected. TLS 1.3 with PQ key exchange is mandatory.
- Add dedicated 20 RPM rate limiter on bridge signing and intent signing
  endpoints (separate from global 100 RPM).
- Add body size limit (1MB), read/write timeouts, and 10 RPM rate limiter
  to internal MPC API server.
- Restrict K8s NetworkPolicy egress for MPC nodes to peers, postgres,
  valkey, DNS, and HTTPS only (was unrestricted).
- Wire cluster-api-key in api-deployment for authenticated proxy to
  internal MPC API. Forward as Authorization: Bearer header.
- Remove hardcoded dev JWT_SECRET default from compose.yml (now required).
- Add internal-api-key to KMSSecret CRD and StatefulSet env.
This commit is contained in:
2025-12-28 02:52:46 -08:00
parent 2c05724232
commit c7687e87ca
11 changed files with 185 additions and 48 deletions
+16
View File
@@ -396,6 +396,22 @@ make e2e-test
19. **Protocol timeouts**: No timeout enforcement on protocol handlers. Recommend adding context with timeout to prevent indefinite hangs from stalling parties.
### Security Hardening (Apr 2026)
23. **Internal API auth (port 9800)**: All mutating endpoints (/keygen, /keys, /backup) now require `Authorization: Bearer <token>`. Token sourced from `MPC_INTERNAL_API_KEY` env var (KMS-synced). Falls back to deterministic derivation from Ed25519 node identity for dev. /health remains unauthenticated for K8s probes.
24. **TLS-only transport**: `DualModeListener` replaced by `TLSOnlyListener`. Plaintext connections are rejected. TLS 1.3 with PQ key exchange (X25519MLKEM768) is mandatory for all peer-to-peer communication.
25. **Sign rate limiting**: Bridge signing and intent signing endpoints have a dedicated 20 RPM per-IP rate limiter, separate from the global 100 RPM limit.
26. **Internal API hardening**: Port 9800 now has body size limits (1 MB), read/write timeouts, and 10 RPM rate limiting on mutating endpoints.
27. **Network egress restriction**: K8s NetworkPolicy for MPC nodes now restricts egress to peer nodes, postgres, valkey, DNS, and HTTPS (S3 backup) only. No unrestricted egress.
28. **Compose.yml secrets**: JWT_SECRET now uses `${JWT_SECRET:?}` (required) instead of a hardcoded dev default.
29. **Cluster API key forwarding**: api-deployment now passes `--cluster-api-key $(MPC_CLUSTER_API_KEY)` when proxying to internal MPC API. The `apiOnlyMPCBackend.doRequest` sends it as `Authorization: Bearer` header.
### Consensus-Embedded Transport (Jan 2026)
20. **ZAP Message Types**: MPC uses ZAP wire protocol message types 60-79:
+68 -10
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"math/big"
"net"
"net/http"
"os"
"os/signal"
@@ -846,10 +847,59 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
}
logger.Info("[READY] Node is ready (consensus mode)", "nodeID", nodeID)
// Start HTTP API server
// Start HTTP API server (internal MPC node API on port 9800)
apiAddr := c.String("api")
if apiAddr != "" {
// Internal API bearer token — required for all endpoints except /health.
// Source: MPC_INTERNAL_API_KEY env var. In production the StatefulSet
// injects this from the KMS-synced mpc-secrets K8s Secret.
internalAPIKey := os.Getenv("MPC_INTERNAL_API_KEY")
if internalAPIKey == "" {
// Derive a deterministic key from the node's Ed25519 private key so
// all nodes in the cluster share the same key without extra config.
// SHA-256(privKey || "mpc-internal-api") truncated to hex.
h := sha256.Sum256(append(privKey.Seed(), []byte("mpc-internal-api")...))
internalAPIKey = hex.EncodeToString(h[:])
logger.Warn("MPC_INTERNAL_API_KEY not set; derived internal API key from node identity (set MPC_INTERNAL_API_KEY in production)")
}
// Rate limiter: 10 requests/min for mutating endpoints (keygen, backup).
internalRL := mpcapi.NewRateLimiter(10)
// internalAuth is middleware that gates all mutating internal endpoints
// behind a bearer token. /health is exempt (K8s probes need it).
internalAuth := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" || auth != "Bearer "+internalAPIKey {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
next.ServeHTTP(w, r)
}
}
// internalRateLimit wraps a handler with the tight rate limiter.
internalRateLimit := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if !internalRL.Allow(ip) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
return
}
next.ServeHTTP(w, r)
}
}
mux := http.NewServeMux()
// /health is unauthenticated (K8s liveness/readiness probes)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
ready := peerRegistry.ArePeersReady()
connected := factory.Transport().GetPeers()
@@ -873,7 +923,7 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
}
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/keys", internalAuth(func(w http.ResponseWriter, r *http.Request) {
keys, err := factory.KeyInfoStore().ListKeys()
w.Header().Set("Content-Type", "application/json")
if err != nil {
@@ -882,13 +932,14 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
return
}
json.NewEncoder(w).Encode(keys)
})
mux.HandleFunc("/backup", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/backup", internalAuth(internalRateLimit(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
logger.Info("Audit: backup triggered", "nodeID", nodeID, "remote", r.RemoteAddr)
if zapKV, ok := factory.KVStore().(*kvstore.Store); ok && zapKV.Exec != nil {
s3Cfg := backup.S3ConfigFromEnv(nodeID)
mgr, err := backup.NewManager(zapKV.Exec, filepath.Join(dataDir, "backups"), nodeID, 0, s3Cfg)
@@ -907,8 +958,8 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "backup not available"})
}
})
mux.HandleFunc("/keygen", func(w http.ResponseWriter, r *http.Request) {
})))
mux.HandleFunc("/keygen", internalAuth(internalRateLimit(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
@@ -969,7 +1020,7 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
return
}
logger.Info("Keygen triggered", "walletID", walletID)
logger.Info("Audit: keygen triggered", "nodeID", nodeID, "walletID", walletID, "remote", r.RemoteAddr)
// Wait for result with 60s timeout
select {
@@ -1000,9 +1051,16 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
"wallet_id": walletID,
})
}
})
})))
srv := &http.Server{Addr: apiAddr, Handler: mux}
srv := &http.Server{
Addr: apiAddr,
Handler: http.MaxBytesHandler(mux, 1<<20), // 1 MB body limit
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 90 * time.Second, // keygen can take 60s
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Info("HTTP API server starting", "addr", apiAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -1869,7 +1927,7 @@ func (a *apiOnlyMPCBackend) doRequest(method, path string, reqBody interface{},
}
req.Header.Set("Content-Type", "application/json")
if a.apiKey != "" {
req.Header.Set("X-API-Key", a.apiKey)
req.Header.Set("Authorization", "Bearer "+a.apiKey)
}
resp, err := a.httpClient.Do(req)
+2 -2
View File
@@ -9,11 +9,11 @@ services:
start --mode consensus
--api-listen :8081
--data /data/mpc
--jwt-secret ${JWT_SECRET:-dev-jwt-secret-change-in-production}
--jwt-secret ${JWT_SECRET:?JWT_SECRET must be set}
ports:
- "8081:8081"
environment:
- JWT_SECRET=${JWT_SECRET:-dev-jwt-secret-change-in-production}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set}
volumes:
- mpc-data:/data/mpc
healthcheck:
+8
View File
@@ -44,6 +44,8 @@ spec:
- $(JWT_SECRET)
- --cluster-url
- http://mpc-node-0.mpc-node-headless.lux-mpc.svc:9800
- --cluster-api-key
- $(MPC_CLUSTER_API_KEY)
ports:
- containerPort: 8081
name: api
@@ -59,6 +61,12 @@ spec:
secretKeyRef:
name: mpc-secrets
key: jwt-secret
- name: MPC_CLUSTER_API_KEY
valueFrom:
secretKeyRef:
name: mpc-secrets
key: internal-api-key
optional: true
livenessProbe:
httpGet:
path: /healthz
+6
View File
@@ -157,6 +157,12 @@ spec:
name: mpc-secrets
key: kms-master-key
optional: true
- name: MPC_INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: mpc-secrets
key: internal-api-key
optional: true
livenessProbe:
httpGet:
path: /health
+39 -2
View File
@@ -39,7 +39,7 @@ spec:
- protocol: TCP
port: 9800
---
# MPC node egress: unrestricted (needs S3, postgres, peers)
# MPC node egress: restricted to peers, data stores, S3, DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
@@ -52,7 +52,44 @@ spec:
policyTypes:
- Egress
egress:
- {}
# mpc-node <-> mpc-node: consensus p2p
- to:
- podSelector:
matchLabels:
app: mpc-node
ports:
- protocol: TCP
port: 9651
- protocol: TCP
port: 9800
# mpc-node -> postgres
- to:
- podSelector:
matchLabels:
app: mpc-postgres
ports:
- protocol: TCP
port: 5432
# mpc-node -> valkey
- to:
- podSelector:
matchLabels:
app: mpc-valkey
ports:
- protocol: TCP
port: 6379
# DNS resolution (kube-dns / CoreDNS)
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# S3 backup (MinIO or external S3 — HTTPS only)
- ports:
- protocol: TCP
port: 443
---
# MPC API ingress: only from ingress controller and dashboard
apiVersion: networking.k8s.io/v1
+2
View File
@@ -26,3 +26,5 @@ spec:
secretKey: KV_URL
- kmsKey: zapdb-password
secretKey: ZAPDB_PASSWORD
- kmsKey: internal-api-key
secretKey: internal-api-key
+6
View File
@@ -32,6 +32,12 @@ func NewRateLimiter(requestsPerMinute int) *RateLimiter {
return rl
}
// Allow returns true if the IP has not exceeded the rate limit.
// Exported for use by other packages (e.g., internal MPC API auth).
func (rl *RateLimiter) Allow(ip string) bool {
return rl.allow(ip)
}
// allow returns true if the IP has not exceeded the rate limit.
func (rl *RateLimiter) allow(ip string) bool {
now := time.Now().Unix()
+3
View File
@@ -187,9 +187,11 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
r.Get("/pay/{token}", s.handlePublicPay)
// Bridge signing endpoints (API key or JWT auth; API key needs "sign" permission)
// Tighter rate limit for signing: 20 RPM per IP (separate from global 100 RPM).
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Use(requirePermission("sign"))
r.Use(RateLimitMiddleware(20))
r.Post("/generate_mpc_sig", s.handleBridgeSign)
r.Post("/complete", s.handleBridgeComplete)
})
@@ -344,6 +346,7 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
r.Get("/settlements/{id}", s.handleGetSettlement)
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin", "signer", "api"))
r.Use(RateLimitMiddleware(20)) // signing rate limit: 20 RPM per IP
r.Post("/intents", s.handleCreateIntent)
r.Post("/intents/{id}/sign", s.handleSignIntent)
r.Post("/intents/{id}/co-sign", s.handleCoSignIntent)
+21 -13
View File
@@ -119,20 +119,27 @@ func ListenTLS(addr string, nodeID string, privKey ed25519.PrivateKey, pubKey ed
return tls.Listen("tcp", addr, tlsConfig)
}
// DualModeListener accepts both TLS and plaintext connections by sniffing the
// first byte. TLS records start with 0x16 (ContentType handshake). This enables
// rolling upgrades where some peers are TLS-enabled and others aren't yet.
type DualModeListener struct {
// TLSOnlyListener wraps a net.Listener to enforce TLS on all connections.
// Plaintext connections are rejected immediately. This replaces the former
// DualModeListener which accepted plaintext as a migration aid. Migration
// is complete — all MPC nodes must use TLS 1.3.
type TLSOnlyListener struct {
inner net.Listener
tlsConfig *tls.Config
}
// NewDualModeListener wraps a net.Listener to auto-detect TLS vs plaintext.
func NewDualModeListener(inner net.Listener, tlsConfig *tls.Config) net.Listener {
return &DualModeListener{inner: inner, tlsConfig: tlsConfig}
// NewTLSOnlyListener wraps a net.Listener to enforce TLS.
func NewTLSOnlyListener(inner net.Listener, tlsConfig *tls.Config) net.Listener {
return &TLSOnlyListener{inner: inner, tlsConfig: tlsConfig}
}
func (l *DualModeListener) Accept() (net.Conn, error) {
// NewDualModeListener is a deprecated alias for NewTLSOnlyListener.
// Retained for compile compatibility; plaintext is no longer accepted.
func NewDualModeListener(inner net.Listener, tlsConfig *tls.Config) net.Listener {
return NewTLSOnlyListener(inner, tlsConfig)
}
func (l *TLSOnlyListener) Accept() (net.Conn, error) {
conn, err := l.inner.Accept()
if err != nil {
return nil, err
@@ -143,7 +150,7 @@ func (l *DualModeListener) Accept() (net.Conn, error) {
n, err := conn.Read(peek)
if err != nil || n == 0 {
conn.Close()
return nil, fmt.Errorf("dual-mode: failed to peek: %w", err)
return nil, fmt.Errorf("tls-only: failed to peek: %w", err)
}
// Prepend the peeked byte back
@@ -154,12 +161,13 @@ func (l *DualModeListener) Accept() (net.Conn, error) {
return tls.Server(pconn, l.tlsConfig), nil
}
// Plaintext connection — pass through
return pconn, nil
// Plaintext connection — reject. Do not accept unencrypted MPC traffic.
conn.Close()
return nil, fmt.Errorf("tls-only: rejected plaintext connection from %s", conn.RemoteAddr())
}
func (l *DualModeListener) Close() error { return l.inner.Close() }
func (l *DualModeListener) Addr() net.Addr { return l.inner.Addr() }
func (l *TLSOnlyListener) Close() error { return l.inner.Close() }
func (l *TLSOnlyListener) Addr() net.Addr { return l.inner.Addr() }
// prependConn prepends buffered bytes before the underlying connection data.
type prependConn struct {
+14 -21
View File
@@ -120,28 +120,21 @@ func New(config *Config) (*Transport, error) {
// Start starts the transport (listener and peer connections)
func (t *Transport) Start(ctx context.Context) error {
// Start dual-mode listener: accepts both TLS 1.3+PQ and plaintext connections.
// This allows rolling upgrades where some nodes have TLS and others don't yet.
var listener net.Listener
var err error
if t.config.PrivateKey != nil && t.config.PublicKey != nil {
tlsConfig, tlsErr := NewServerTLSConfig(t.config.NodeID, t.config.PrivateKey, t.config.PublicKey)
if tlsErr != nil {
return fmt.Errorf("failed to create TLS config: %w", tlsErr)
}
listener, err = net.Listen("tcp", t.config.ListenAddr)
if err != nil {
return fmt.Errorf("failed to listen: %w", err)
}
listener = NewDualModeListener(listener, tlsConfig)
logger.Info("Transport listening with PQ TLS 1.3 (dual-mode)", "addr", listener.Addr().String())
} else {
listener, err = net.Listen("tcp", t.config.ListenAddr)
if err != nil {
return fmt.Errorf("failed to listen: %w", err)
}
logger.Warn("Transport listening WITHOUT TLS (no identity keys)", "addr", listener.Addr().String())
// TLS 1.3 with PQ key exchange is mandatory. No plaintext fallback.
if t.config.PrivateKey == nil || t.config.PublicKey == nil {
return fmt.Errorf("TLS identity keys are required — cannot start transport without Ed25519 keys")
}
tlsConfig, tlsErr := NewServerTLSConfig(t.config.NodeID, t.config.PrivateKey, t.config.PublicKey)
if tlsErr != nil {
return fmt.Errorf("failed to create TLS config: %w", tlsErr)
}
var listener net.Listener
listener, err := net.Listen("tcp", t.config.ListenAddr)
if err != nil {
return fmt.Errorf("failed to listen: %w", err)
}
listener = NewTLSOnlyListener(listener, tlsConfig)
logger.Info("Transport listening with PQ TLS 1.3 (TLS-only)", "addr", listener.Addr().String())
t.listener = listener
logger.Info("Transport listening", "addr", listener.Addr().String())