diff --git a/pkg/membership/auth.go b/pkg/membership/auth.go index 4b19234..719b8c2 100644 --- a/pkg/membership/auth.go +++ b/pkg/membership/auth.go @@ -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 } diff --git a/pkg/membership/auth_test.go b/pkg/membership/auth_test.go index 6cc3445..4cff562 100644 --- a/pkg/membership/auth_test.go +++ b/pkg/membership/auth_test.go @@ -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) } diff --git a/pkg/membership/authz_test.go b/pkg/membership/authz_test.go new file mode 100644 index 0000000..0498a54 --- /dev/null +++ b/pkg/membership/authz_test.go @@ -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) +} diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 50c0465..99d0b4f 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -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{