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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user