feat(membership): signed control-plane auth middleware + anti-replay
Adds the bus-auth rollout (off|soft|enforce) to the control plane. The middleware verifies an Ed25519 request signature over CanonicalRequest (method, request-URI, ts, nonce, sha256(body)), checks the timestamp is within +/-30s, rejects replayed nonces via an in-memory TTL cache (60s), and requires the signer to be an active user in the allowlist. soft logs rejections but lets requests through so clients can migrate without an outage; off is the legacy no-op default. /healthz is exempt so health probes work before any identity exists. CanonicalRequest is exported as the single source of truth shared with the client. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
package membership
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
@@ -24,20 +27,66 @@ import (
|
||||
// rate limiting, and read endpoints (GET) are unauthenticated. Hardening
|
||||
// (mTLS, capabilities, rate limits) is a later phase.
|
||||
type Server struct {
|
||||
store *Store
|
||||
blobs *blobstore.Store
|
||||
mux *http.ServeMux
|
||||
store *Store
|
||||
blobs *blobstore.Store
|
||||
mux *http.ServeMux
|
||||
authMode AuthMode
|
||||
nonces *nonceCache
|
||||
}
|
||||
|
||||
// NewServer wires the membership store and blob store into an http.Handler.
|
||||
func NewServer(store *Store, blobs *blobstore.Store) *Server {
|
||||
s := &Server{store: store, blobs: blobs, mux: http.NewServeMux()}
|
||||
// NewServer wires the membership store and blob store into an http.Handler. The
|
||||
// authMode selects the control-plane auth rollout state (AuthOff for callers and
|
||||
// tests that have not migrated to signed requests yet).
|
||||
func NewServer(store *Store, blobs *blobstore.Store, authMode AuthMode) *Server {
|
||||
s := &Server{
|
||||
store: store,
|
||||
blobs: blobs,
|
||||
mux: http.NewServeMux(),
|
||||
authMode: authMode,
|
||||
nonces: newNonceCache(nonceTTL),
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP satisfies http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
||||
// ServeHTTP satisfies http.Handler. It runs the control-plane auth middleware
|
||||
// (signature verification + anti-replay + allowlist) ahead of the router
|
||||
// according to authMode, then dispatches to the matched handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authMode == AuthOff || isAuthExempt(r) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Buffer the body so the signature can be verified over it and the handler
|
||||
// still reads it. Bodies on the control plane are small (JSON metadata or a
|
||||
// media blob already capped upstream), so full buffering is acceptable.
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
if _, err := s.authenticate(r, body, time.Now()); err != nil {
|
||||
if s.authMode == AuthSoft {
|
||||
log.Printf("[auth] soft: would reject %s %s: %v", r.Method, r.URL.Path, err)
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
||||
// Only the unauthenticated health probe qualifies: it carries no data and is
|
||||
// needed by load balancers / smoke checks / systemd before any identity exists.
|
||||
func isAuthExempt(r *http.Request) bool {
|
||||
return r.Method == http.MethodGet && r.URL.Path == "/healthz"
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
|
||||
Reference in New Issue
Block a user