Merge issue/0004c-membership-authz: membership-based authorization (H3)

Room metadata, member lists, room directories and sealed keys are now served
only to members of the room (and a sealed key only to its own endpoint),
closing the horizontal metadata leak.
This commit is contained in:
2026-06-07 14:21:55 +02:00
4 changed files with 218 additions and 14 deletions
+9 -5
View File
@@ -11,6 +11,8 @@ import (
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/frame"
)
// AuthMode is the control-plane authentication rollout state (feature flag
@@ -121,11 +123,13 @@ func (n *nonceCache) rememberOrReject(nonce string, now time.Time) bool {
}
// authResult is what a successful authentication yields: the verified signing
// key (hex) and the authorized user record. Handlers may use it for fine-grained
// authorization (e.g. role checks) in later phases.
// key (hex), the endpoint id derived from it, and the authorized user record.
// Handlers use endpoint for membership authorization (only a member of a room
// may read its metadata/keys); user is available for role checks.
type authResult struct {
pubHex string
user User
pubHex string
endpoint string
user User
}
// authenticate verifies the signature headers on r against body and the user
@@ -181,5 +185,5 @@ 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")
}
return authResult{pubHex: pubHex, user: user}, nil
return authResult{pubHex: pubHex, endpoint: frame.EndpointID(pub), user: user}, nil
}
+16 -4
View File
@@ -15,6 +15,7 @@ import (
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/frame"
)
// authHarness boots an in-process membershipd HTTP server in the given auth mode
@@ -88,13 +89,24 @@ func do(t *testing.T, req *http.Request) (int, string) {
return resp.StatusCode, string(b)
}
const okPath = "/members/alice-endpoint/rooms" // always 200 with an empty list
// okPath is a path that authenticates and returns 200 with an empty list when
// the request carries NO membership-bound signer (AuthOff/soft/missing-headers
// tests). Under enforce, the per-endpoint room directory is now restricted to
// the signer's own endpoint (audit H3), so tests that sign as alice use
// aliceRoomsPath instead.
const okPath = "/members/alice-endpoint/rooms"
// aliceRoomsPath is alice's own room directory — the canonical "authenticated
// and authorized" 200 path under enforce after H3.
func aliceRoomsPath(h *authHarness) string {
return "/members/" + frame.EndpointID(h.alice.SignPub) + "/rooms"
}
// Golden: a request signed by a registered, active identity is accepted.
func TestAuthGoldenAccepted(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
now := time.Now().Unix()
code, _ := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-golden"))
code, _ := do(t, signedReq(t, h.ts.URL, "GET", aliceRoomsPath(h), nil, h.alice, now, "nonce-golden"))
if code != http.StatusOK {
t.Fatalf("golden signed request should be 200, got %d", code)
}
@@ -116,12 +128,12 @@ func TestAuthUnregisteredRejected(t *testing.T) {
func TestAuthReplayRejected(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
now := time.Now().Unix()
first := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay")
first := signedReq(t, h.ts.URL, "GET", aliceRoomsPath(h), nil, h.alice, now, "nonce-replay")
if code, body := do(t, first); code != http.StatusOK {
t.Fatalf("first request should be 200, got %d (%s)", code, body)
}
// Identical ts + nonce + signature: a replay.
second := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay")
second := signedReq(t, h.ts.URL, "GET", aliceRoomsPath(h), nil, h.alice, now, "nonce-replay")
if code, body := do(t, second); code != http.StatusUnauthorized {
t.Fatalf("replayed request should be 401, got %d (%s)", code, body)
}
+119
View File
@@ -0,0 +1,119 @@
package membership
import (
"encoding/hex"
"net/http"
"strconv"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/frame"
)
// seedRoom inserts an encrypted room owned by alice with a sealed key for her,
// directly through the store so the test controls membership precisely. It
// returns the room id and alice's endpoint.
func seedRoom(t *testing.T, h *authHarness, subject string) (string, string) {
t.Helper()
aliceEp := frame.EndpointID(h.alice.SignPub)
roomID := newULID()
info := RoomInfo{RoomID: roomID, Subject: subject, OwnerEndpoint: aliceEp, Encrypt: true}
if err := h.store.CreateRoom(info, h.alice.SignPub, h.alice.KexPub, []byte("alice-sealed-key")); err != nil {
t.Fatalf("seed room: %v", err)
}
return roomID, aliceEp
}
// register adds id to the bus allowlist so its signed requests clear auth and
// reach the handler, where membership authorization (not mere registration) is
// what the test exercises.
func register(t *testing.T, h *authHarness, id cs.Identity, handle string) {
t.Helper()
if err := h.store.AddUser(hex.EncodeToString(id.SignPub), handle, RoleMember); err != nil {
t.Fatalf("register %s: %v", handle, err)
}
}
// TestAudit_HorizontalMetadataLeak ports the auditor's H3 (Alto) finding: bob is
// REGISTERED on the bus but is NOT a member of alice's room. Before the fix the
// GET endpoints checked registration, not membership, so bob could read the
// room's subject, the full member list (with everyone's public keys), alice's
// room directory, and even alice's sealed key. Now every one of those returns
// 403 to bob, while alice (owner/member) and carol (plain member) get 200.
func TestAudit_HorizontalMetadataLeak(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
roomID, aliceEp := seedRoom(t, h, "secret.subject.payroll")
// bob: registered, never invited.
bob, _ := cs.GenerateIdentity()
register(t, h, bob, "bob")
// carol: registered AND a plain (non-owner) member — the legitimate-member edge.
carol, _ := cs.GenerateIdentity()
register(t, h, carol, "carol")
carolEp := frame.EndpointID(carol.SignPub)
if err := h.store.AddMember(roomID, Member{Endpoint: carolEp, Role: RoleMember, SignPub: carol.SignPub, KexPub: carol.KexPub}, 1, []byte("carol-sealed")); err != nil {
t.Fatalf("add carol: %v", err)
}
n := 0
get := func(id cs.Identity, path string) int {
n++
code, _ := do(t, signedReq(t, h.ts.URL, "GET", path, nil, id, time.Now().Unix(), nonceN(n)))
return code
}
// Error path: bob (non-member) is forbidden on every room endpoint.
bobChecks := []struct {
name string
path string
}{
{"get room", "/rooms/" + roomID},
{"list members", "/rooms/" + roomID + "/members"},
{"alice room directory", "/members/" + aliceEp + "/rooms"},
{"alice sealed key", "/rooms/" + roomID + "/key?endpoint=" + aliceEp},
{"bob sealed key in alices room", "/rooms/" + roomID + "/key?endpoint=" + frame.EndpointID(bob.SignPub)},
}
for _, c := range bobChecks {
if code := get(bob, c.path); code != http.StatusForbidden {
t.Fatalf("bob (non-member) %s should be 403, got %d", c.name, code)
}
}
// Golden: alice (owner/member) reads her room's metadata, members, directory, key.
aliceChecks := []string{
"/rooms/" + roomID,
"/rooms/" + roomID + "/members",
"/members/" + aliceEp + "/rooms",
"/rooms/" + roomID + "/key?endpoint=" + aliceEp,
}
for _, p := range aliceChecks {
if code := get(h.alice, p); code != http.StatusOK {
t.Fatalf("alice (owner) %s should be 200, got %d", p, code)
}
}
// Edge: carol is a plain member, not the owner — she may still read the room.
if code := get(carol, "/rooms/"+roomID); code != http.StatusOK {
t.Fatalf("carol (member) get room should be 200, got %d", code)
}
if code := get(carol, "/rooms/"+roomID+"/members"); code != http.StatusOK {
t.Fatalf("carol (member) list members should be 200, got %d", code)
}
// Edge: carol may fetch her OWN sealed key but not alice's.
if code := get(carol, "/rooms/"+roomID+"/key?endpoint="+carolEp); code != http.StatusOK {
t.Fatalf("carol fetching her own key should be 200, got %d", code)
}
if code := get(carol, "/rooms/"+roomID+"/key?endpoint="+aliceEp); code != http.StatusForbidden {
t.Fatalf("carol fetching alice's key should be 403, got %d", code)
}
}
// nonceN yields a distinct nonce per request so the anti-replay cache never
// rejects a fresh, legitimately-different request inside one test.
func nonceN(i int) string {
return "authz-nonce-" + strconv.Itoa(i)
}
+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{