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"
|
"time"
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
|
||||||
|
"github.com/enmanuel/unibus/pkg/frame"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthMode is the control-plane authentication rollout state (feature flag
|
// 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
|
// authResult is what a successful authentication yields: the verified signing
|
||||||
// key (hex) and the authorized user record. Handlers may use it for fine-grained
|
// key (hex), the endpoint id derived from it, and the authorized user record.
|
||||||
// authorization (e.g. role checks) in later phases.
|
// 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 {
|
type authResult struct {
|
||||||
pubHex string
|
pubHex string
|
||||||
user User
|
endpoint string
|
||||||
|
user User
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate verifies the signature headers on r against body and the 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.
|
// IsAuthorized passed but the row vanished (race with revoke): fail closed.
|
||||||
return authResult{}, fmt.Errorf("identity not authorized")
|
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"
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
"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
|
// 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)
|
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.
|
// Golden: a request signed by a registered, active identity is accepted.
|
||||||
func TestAuthGoldenAccepted(t *testing.T) {
|
func TestAuthGoldenAccepted(t *testing.T) {
|
||||||
h := newAuthHarness(t, AuthEnforce)
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
now := time.Now().Unix()
|
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 {
|
if code != http.StatusOK {
|
||||||
t.Fatalf("golden signed request should be 200, got %d", code)
|
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) {
|
func TestAuthReplayRejected(t *testing.T) {
|
||||||
h := newAuthHarness(t, AuthEnforce)
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
now := time.Now().Unix()
|
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 {
|
if code, body := do(t, first); code != http.StatusOK {
|
||||||
t.Fatalf("first request should be 200, got %d (%s)", code, body)
|
t.Fatalf("first request should be 200, got %d (%s)", code, body)
|
||||||
}
|
}
|
||||||
// Identical ts + nonce + signature: a replay.
|
// 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 {
|
if code, body := do(t, second); code != http.StatusUnauthorized {
|
||||||
t.Fatalf("replayed request should be 401, got %d (%s)", code, body)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -130,7 +131,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = r.Body.Close()
|
_ = r.Body.Close()
|
||||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
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 {
|
if s.authMode == AuthSoft {
|
||||||
log.Printf("[auth] soft: would reject %s %s: %v", r.Method, r.URL.Path, err)
|
log.Printf("[auth] soft: would reject %s %s: %v", r.Method, r.URL.Path, err)
|
||||||
s.mux.ServeHTTP(w, r)
|
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())
|
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
||||||
return
|
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
|
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
||||||
@@ -149,6 +153,43 @@ func isBodyTooLarge(err error) bool {
|
|||||||
return errors.As(err, &maxErr)
|
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.
|
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
||||||
// Only the unauthenticated health probe qualifies: it carries no data and is
|
// Only the unauthenticated health probe qualifies: it carries no data and is
|
||||||
// needed by load balancers / smoke checks / systemd before any identity exists.
|
// 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")
|
writeErr(w, http.StatusBadRequest, "endpoint query param required")
|
||||||
return
|
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
|
epoch := 0
|
||||||
if e := r.URL.Query().Get("epoch"); e != "" {
|
if e := r.URL.Query().Get("epoch"); e != "" {
|
||||||
if n, err := strconv.Atoi(e); err == nil {
|
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) {
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
roomID := r.PathValue("id")
|
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)
|
members, err := s.store.ListMembers(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberJSON, 0, len(members))
|
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")
|
writeErr(w, http.StatusBadRequest, "endpoint required")
|
||||||
return
|
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)
|
rooms, err := s.store.ListRoomsForEndpoint(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberRoomJSON, 0, len(rooms))
|
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) {
|
func (s *Server) handleGetRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
roomID := r.PathValue("id")
|
roomID := r.PathValue("id")
|
||||||
|
if _, ok := s.requireMember(w, r, roomID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
info, err := s.store.GetRoom(roomID)
|
info, err := s.store.GetRoom(roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusNotFound, err.Error())
|
writeErr(w, http.StatusNotFound, "room not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, roomResp{
|
writeJSON(w, http.StatusOK, roomResp{
|
||||||
|
|||||||
Reference in New Issue
Block a user