mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -26,3 +26,5 @@ spec:
|
||||
secretKey: KV_URL
|
||||
- kmsKey: zapdb-password
|
||||
secretKey: ZAPDB_PASSWORD
|
||||
- kmsKey: internal-api-key
|
||||
secretKey: internal-api-key
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user