diff --git a/pkg/membership/auth.go b/pkg/membership/auth.go index 719b8c2..c4cf354 100644 --- a/pkg/membership/auth.go +++ b/pkg/membership/auth.go @@ -75,6 +75,11 @@ const ( const ( clockSkew = 30 * time.Second nonceTTL = 60 * time.Second + // maxNonceCacheEntries bounds the replay cache so it cannot grow without limit + // (audit H7). With IsAuthorized now gating insertion, only authorized traffic + // is cached, so this ceiling is only approached under a legitimate burst; at + // the cap the oldest nonce is evicted (its TTL is nearly up anyway). + maxNonceCacheEntries = 100_000 ) // CanonicalRequest returns the exact bytes that are signed and verified for a @@ -91,34 +96,64 @@ func CanonicalRequest(method, path, ts, nonce string, body []byte) []byte { } // nonceCache remembers recently-seen nonces to reject replays. It is an -// in-memory map guarded by a mutex with lazy expiry — sufficient for a single -// membershipd process (the spec's chosen tradeoff over a server-issued nonce -// round-trip). A distributed deployment would need a shared store. +// in-memory store guarded by a mutex — sufficient for a single membershipd +// process (the spec's chosen tradeoff over a server-issued nonce round-trip). A +// distributed deployment would need a shared store (tracked for issue 0003). +// +// Pruning is O(expired), not O(n): because the TTL is constant, insertion order +// equals expiry order, so the oldest entries (front of `order`) are exactly the +// ones that expire first (audit H7 — the previous full-map scan under the mutex +// was a CPU-amplification vector). A size cap bounds memory. type nonceCache struct { - mu sync.Mutex - seen map[string]time.Time - ttl time.Duration + mu sync.Mutex + seen map[string]time.Time // nonce -> expiry + order []string // nonces in insertion order == expiry order + ttl time.Duration + cap int } -func newNonceCache(ttl time.Duration) *nonceCache { - return &nonceCache{seen: make(map[string]time.Time), ttl: ttl} +func newNonceCache(ttl time.Duration, capacity int) *nonceCache { + return &nonceCache{seen: make(map[string]time.Time), ttl: ttl, cap: capacity} } // rememberOrReject records nonce and returns true if it was unseen, or false if -// it is a replay (still live in the cache). Expired entries are pruned lazily on -// each call so the map cannot grow without bound under steady traffic. +// it is a replay (still live in the cache). func (n *nonceCache) rememberOrReject(nonce string, now time.Time) bool { n.mu.Lock() defer n.mu.Unlock() - for k, exp := range n.seen { - if exp.Before(now) { - delete(n.seen, k) + + // Prune expired entries from the front (oldest first). The first live entry + // ends the scan — everything behind it was inserted later and is newer. + cut := 0 + for cut < len(n.order) { + exp, ok := n.seen[n.order[cut]] + if !ok { + cut++ // already evicted by the cap path below + continue } + if !exp.Before(now) { + break + } + delete(n.seen, n.order[cut]) + cut++ } + if cut > 0 { + n.order = append(n.order[:0], n.order[cut:]...) + } + if exp, ok := n.seen[nonce]; ok && !exp.Before(now) { - return false + return false // a live replay } + + // Bound memory: at capacity, evict the oldest entry (its TTL is nearly up). + for len(n.seen) >= n.cap && len(n.order) > 0 { + oldest := n.order[0] + n.order = n.order[1:] + delete(n.seen, oldest) + } + n.seen[nonce] = now.Add(n.ttl) + n.order = append(n.order, nonce) return true } @@ -172,10 +207,9 @@ func (s *Server) authenticate(r *http.Request, body []byte, now time.Time) (auth return authResult{}, fmt.Errorf("invalid signature") } - if !s.nonces.rememberOrReject(nonce, now) { - return authResult{}, fmt.Errorf("replayed nonce") - } - + // Authorize BEFORE touching the replay cache (audit H7): an unregistered + // identity can mint valid signatures for free, so caching its nonces would let + // it poison/grow the cache pre-auth. Only authorized identities are remembered. if !s.store.IsAuthorized(pubHex) { return authResult{}, fmt.Errorf("identity not authorized") } @@ -185,5 +219,12 @@ func (s *Server) authenticate(r *http.Request, body []byte, now time.Time) (auth // IsAuthorized passed but the row vanished (race with revoke): fail closed. return authResult{}, fmt.Errorf("identity not authorized") } + + // Anti-replay last: a replayed request from an authorized identity is still + // rejected here (the nonce is already live in the cache from its first use). + if !s.nonces.rememberOrReject(nonce, now) { + return authResult{}, fmt.Errorf("replayed nonce") + } + return authResult{pubHex: pubHex, endpoint: frame.EndpointID(pub), user: user}, nil } diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 3062e8f..c5a2724 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -19,6 +19,7 @@ import ( "golang.org/x/time/rate" "github.com/enmanuel/unibus/pkg/blobstore" + "github.com/enmanuel/unibus/pkg/frame" ) // Body-size ceilings for the control plane. They bound how much an unauthenticated @@ -84,7 +85,7 @@ func NewServer(store *Store, blobs *blobstore.Store, authMode AuthMode) *Server blobs: blobs, mux: http.NewServeMux(), authMode: authMode, - nonces: newNonceCache(nonceTTL), + nonces: newNonceCache(nonceTTL, maxNonceCacheEntries), limiter: newIPRateLimiter(defaultRatePerSec, defaultRateBurst, rateBucketTTL), } s.routes() @@ -307,6 +308,15 @@ func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } +// writeServerErr logs the internal error detail and returns ONLY a generic +// message to the client (audit H12): raw store/blob errors embed SQL fragments +// and filesystem paths, which must not leak to a caller. Use it for any error +// that originates inside the server (5xx, or a not-found wrapping a store error). +func writeServerErr(w http.ResponseWriter, r *http.Request, code int, publicMsg string, err error) { + log.Printf("[handler] %s %s -> %d: %v", r.Method, r.URL.Path, code, err) + writeErr(w, code, publicMsg) +} + // canonicalSig returns the bytes to verify for a request: the request struct // re-marshaled with its Sig field cleared. The caller passes a copy with Sig // already zeroed. This is symmetric with how the client signs. @@ -359,6 +369,16 @@ func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { "cleartext rooms are disabled on this deployment; create an encrypted (Matrix-policy) room") return } + // Owner binding (audit H6): the declared owner must BE the authenticated + // signer — both the endpoint id and the signing key. Otherwise a registered + // peer could create rooms in another identity's name. Enforced only when an + // authenticated signer is present (AuthOff/dev trusts the caller). + if signer, ok := signerEndpoint(r); ok { + if req.Owner.Endpoint != signer || frame.EndpointID(req.Owner.SignPub) != signer { + writeErr(w, http.StatusForbidden, "forbidden: room owner must be the authenticated signer") + return + } + } roomID := newULID() info := RoomInfo{ RoomID: roomID, @@ -369,7 +389,7 @@ func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { OwnerEndpoint: req.Owner.Endpoint, } if err := s.store.CreateRoom(info, req.Owner.SignPub, req.Owner.KexPub, req.SealedKeySelf); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } writeJSON(w, http.StatusCreated, createRoomResp{RoomID: roomID}) @@ -391,7 +411,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { } info, err := s.store.GetRoom(roomID) if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) + writeServerErr(w, r, http.StatusNotFound, "room not found", err) return } m := Member{ @@ -401,7 +421,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { KexPub: req.Member.KexPub, } if err := s.store.AddMember(roomID, m, info.Epoch, req.SealedKey); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "invited"}) @@ -441,7 +461,7 @@ func (s *Server) handleGetKey(w http.ResponseWriter, r *http.Request) { "not invited to this encrypted room: no key has been sealed for your identity. Ask the room owner to invite you before joining.") return } - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } writeJSON(w, http.StatusOK, keyResp{Epoch: ep, SealedKey: sealed}) @@ -533,7 +553,7 @@ func (s *Server) handleRekey(w http.ResponseWriter, r *http.Request) { // Bump epoch, then store the fresh sealed keys for the remaining members, // then remove the kicked/left members. if err := s.store.BumpEpoch(roomID, req.NewEpoch); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } keys := make(map[string][]byte, len(req.Keys)) @@ -542,13 +562,13 @@ func (s *Server) handleRekey(w http.ResponseWriter, r *http.Request) { } if len(keys) > 0 { if err := s.store.PutSealedKeys(roomID, req.NewEpoch, keys); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } } for _, ep := range req.Remove { if err := s.store.RemoveMember(roomID, ep); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } } @@ -572,7 +592,7 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request) { } hash, err := s.blobs.Put(data) if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) return } writeJSON(w, http.StatusOK, blobResp{Hash: hash}) @@ -586,7 +606,7 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { } data, err := s.blobs.Get(hash) if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) + writeServerErr(w, r, http.StatusNotFound, "not found", err) return } w.Header().Set("Content-Type", "application/octet-stream")