feat(membership): authorize room reads by membership, not registration

Audit H3 (Alto). 'Authorized' meant 'registered in the allowlist', not 'member
of the room', so any registered peer could read another room's subject, its
full member list (every member's sign_pub + kex_pub), any endpoint's room
directory, and even another member's sealed key.

The middleware now carries the authenticated signer's endpoint id into the
handler via request context. Room handlers enforce membership:
  - GET /rooms/{id} and /rooms/{id}/members require the signer to be a member;
  - GET /rooms/{id}/key serves the sealed key only to its own endpoint
    (endpoint == signer) and only to a member;
  - GET /members/{endpoint}/rooms is restricted to the signer's own endpoint.

Authorization is skipped only when no authenticated signer is present (AuthOff
dev, or a soft-mode pass-through), preserving legacy/dev behavior. Internal
errors no longer echo store messages to the client on these paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:21:55 +02:00
parent d742f91881
commit b81e5f26f1
4 changed files with 218 additions and 14 deletions
+74 -5
View File
@@ -2,6 +2,7 @@ package membership
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
@@ -130,7 +131,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
if _, err := s.authenticate(r, body, now); err != nil {
res, err := s.authenticate(r, body, now)
if 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)
@@ -139,7 +141,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
return
}
s.mux.ServeHTTP(w, r)
// Carry the authenticated signer's endpoint into the handler so room handlers
// can authorize by membership (audit H3). Only set on a verified identity.
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint)))
}
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
@@ -149,6 +153,43 @@ func isBodyTooLarge(err error) bool {
return errors.As(err, &maxErr)
}
// ctxKey is the unexported type for this package's request-context keys, so the
// values cannot collide with keys set by other packages.
type ctxKey int
const ctxSignerEndpoint ctxKey = iota
// withSigner returns a context carrying the authenticated signer's endpoint id.
func withSigner(ctx context.Context, endpoint string) context.Context {
return context.WithValue(ctx, ctxSignerEndpoint, endpoint)
}
// signerEndpoint returns the authenticated signer's endpoint id and whether one
// is present. It is absent under AuthOff (no verification) and when a soft-mode
// request was let through unauthenticated — in both cases membership
// authorization is skipped, preserving dev/legacy behavior.
func signerEndpoint(r *http.Request) (string, bool) {
v, ok := r.Context().Value(ctxSignerEndpoint).(string)
return v, ok && v != ""
}
// requireMember authorizes a room request by membership (audit H3): it returns
// the signer endpoint and true when the request may proceed, or writes 403 and
// returns false when an authenticated signer is not a member of roomID. When no
// authenticated signer is present (AuthOff/dev, or soft pass-through) it allows
// the request — membership is only enforced once the caller's identity is known.
func (s *Server) requireMember(w http.ResponseWriter, r *http.Request, roomID string) (string, bool) {
signer, ok := signerEndpoint(r)
if !ok {
return "", true
}
if _, err := s.store.GetMember(roomID, signer); err != nil {
writeErr(w, http.StatusForbidden, "forbidden: not a member of this room")
return signer, false
}
return signer, true
}
// 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.
@@ -355,6 +396,20 @@ func (s *Server) handleGetKey(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "endpoint query param required")
return
}
// A sealed room key is sealed to one identity's X25519 key. Serving it only to
// that identity (the signer) stops a registered peer from harvesting another
// member's sealed key (audit H3). Membership is implied by owning a sealed key,
// but we also require the signer to be a member for defense in depth.
if signer, ok := signerEndpoint(r); ok {
if endpoint != signer {
writeErr(w, http.StatusForbidden, "forbidden: may only fetch your own sealed key")
return
}
if _, err := s.store.GetMember(roomID, signer); err != nil {
writeErr(w, http.StatusForbidden, "forbidden: not a member of this room")
return
}
}
epoch := 0
if e := r.URL.Query().Get("epoch"); e != "" {
if n, err := strconv.Atoi(e); err == nil {
@@ -376,9 +431,14 @@ func (s *Server) handleGetKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
// Membership authorization (audit H3): the member list exposes every member's
// sign_pub + kex_pub, so it must not be served to a non-member.
if _, ok := s.requireMember(w, r, roomID); !ok {
return
}
members, err := s.store.ListMembers(roomID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
out := make([]memberJSON, 0, len(members))
@@ -394,9 +454,15 @@ func (s *Server) handleListMemberRooms(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "endpoint required")
return
}
// A peer may only enumerate its OWN room directory (audit H3): otherwise any
// registered identity could map another's entire social graph of rooms.
if signer, ok := signerEndpoint(r); ok && endpoint != signer {
writeErr(w, http.StatusForbidden, "forbidden: may only list your own rooms")
return
}
rooms, err := s.store.ListRoomsForEndpoint(endpoint)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
out := make([]memberRoomJSON, 0, len(rooms))
@@ -414,9 +480,12 @@ func (s *Server) handleListMemberRooms(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
if _, ok := s.requireMember(w, r, roomID); !ok {
return
}
info, err := s.store.GetRoom(roomID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
writeErr(w, http.StatusNotFound, "room not found")
return
}
writeJSON(w, http.StatusOK, roomResp{