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:
2026-06-07 14:36:22 +02:00
parent 957b728160
commit 0aa2caae43
2 changed files with 89 additions and 28 deletions
+30 -10
View File
@@ -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")