feat(membership): owner binding, pre-auth nonce-cache fix, generic errors
Three medium audit findings. H6 (owner spoof): handleCreateRoom now binds the body's declared owner to the authenticated signer — both the endpoint id and the signing key must be the signer's — so a registered peer cannot create rooms in another identity's name. Enforced only when an authenticated signer is present. H7 (nonce-cache poison pre-auth): IsAuthorized now runs BEFORE the replay cache is touched, so an unregistered identity (Ed25519 keys are free) can no longer seed nonces into it. The cache is rewritten with O(expired) pruning (insertion order equals expiry order under a constant TTL) instead of the previous O(n) full-map scan under the mutex, plus a size cap with oldest-eviction. This is the prerequisite the 0003 replicated nonce store builds on. H12 (error leak): internal store/blob errors are logged and replaced with a generic client message via writeServerErr, so SQL fragments and filesystem paths no longer reach the caller. Crafted 4xx messages (owner-sig, validation) are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-10
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user