From 2ba40701b27a63197d9f7550524cf4e54d3f5c0d Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 15:27:38 +0200 Subject: [PATCH] feat(membership): add GET /api/directory for endpoint->handle resolution Authenticated bus users (member or admin) can now map a sender's endpoint id back to a readable handle. The endpoint is derived server-side from each user's sign_pub with frame.EndpointID (base64url(sha256(signPub)), unpadded), matching the bus's own construction byte-for-byte. Only active users are listed; under enforce the existing auth middleware rejects an unauthenticated caller with 401. Tests cover the golden path (two users -> 200 with handles + endpoints), the auth contract (unsigned -> 401), revoked-user exclusion, and endpoint parity against the cross-language vector from cmd/busvectors. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/membership/directory_test.go | 153 +++++++++++++++++++++++++++++++ pkg/membership/server.go | 60 ++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 pkg/membership/directory_test.go diff --git a/pkg/membership/directory_test.go b/pkg/membership/directory_test.go new file mode 100644 index 00000000..5a495178 --- /dev/null +++ b/pkg/membership/directory_test.go @@ -0,0 +1,153 @@ +package membership + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "testing" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/frame" +) + +// directory signs a GET /api/directory as id and decodes the response envelope. +func directory(t *testing.T, h *authHarness, id cs.Identity, n int) (int, directoryResp) { + t.Helper() + code, body := signedJSON(t, h, "GET", "/api/directory", nil, id, n) + var resp directoryResp + if code == http.StatusOK { + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("decode directory: %v (%s)", err, body) + } + } + return code, resp +} + +// findMember returns the directory entry for a signing key (case-insensitive). +func findMember(members []directoryMember, signPub string) (directoryMember, bool) { + want := normalizeSignPub(signPub) + for _, m := range members { + if normalizeSignPub(m.SignPub) == want { + return m, true + } + } + return directoryMember{}, false +} + +// TestDirectoryGolden is the happy path: an authenticated bus user (here the seed +// admin alice, plus a registered member bob) reads the directory and gets every +// active user's handle, role, and an endpoint derived server-side from the +// sign_pub with the bus's own construction (frame.EndpointID). Two users in -> +// 200 with both handles and correct endpoints. +func TestDirectoryGolden(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + bob, _ := cs.GenerateIdentity() + register(t, h, bob, "bob") // role member + bobPub := hex.EncodeToString(bob.SignPub) + + code, resp := directory(t, h, h.alice, 1) + if code != http.StatusOK { + t.Fatalf("directory should be 200 for an authenticated user, got %d", code) + } + + aliceRow, ok := findMember(resp.Members, h.alicePub) + if !ok { + t.Fatalf("seed admin alice missing from directory: %+v", resp.Members) + } + if aliceRow.Handle != "alice" || aliceRow.Role != RoleAdmin { + t.Fatalf("alice row wrong: %+v", aliceRow) + } + if want := frame.EndpointID(h.alice.SignPub); aliceRow.Endpoint != want { + t.Fatalf("alice endpoint = %q, want %q", aliceRow.Endpoint, want) + } + + bobRow, ok := findMember(resp.Members, bobPub) + if !ok { + t.Fatalf("registered member bob missing from directory: %+v", resp.Members) + } + if bobRow.Handle != "bob" || bobRow.Role != RoleMember { + t.Fatalf("bob row wrong: %+v", bobRow) + } + if want := frame.EndpointID(bob.SignPub); bobRow.Endpoint != want { + t.Fatalf("bob endpoint = %q, want %q", bobRow.Endpoint, want) + } +} + +// TestDirectoryUnauthenticatedRejected is the auth contract: under enforce an +// unsigned GET /api/directory is rejected with 401 by the middleware, before the +// handler ever runs — the directory is not public. +func TestDirectoryUnauthenticatedRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + req, _ := http.NewRequest("GET", h.ts.URL+"/api/directory", nil) + code, _ := do(t, req) + if code != http.StatusUnauthorized { + t.Fatalf("unsigned directory request under enforce should be 401, got %d", code) + } +} + +// TestDirectoryExcludesRevoked: a revoked user must not appear in the directory +// (status=active filter), while active users still do. +func TestDirectoryExcludesRevoked(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + gone, _ := cs.GenerateIdentity() + register(t, h, gone, "gone") + gonePub := hex.EncodeToString(gone.SignPub) + if err := h.store.RevokeUser(gonePub); err != nil { + t.Fatalf("revoke gone: %v", err) + } + + code, resp := directory(t, h, h.alice, 1) + if code != http.StatusOK { + t.Fatalf("directory should be 200, got %d", code) + } + if _, ok := findMember(resp.Members, gonePub); ok { + t.Fatalf("revoked user must not appear in directory: %+v", resp.Members) + } + if _, ok := findMember(resp.Members, h.alicePub); !ok { + t.Fatalf("active admin alice should still appear: %+v", resp.Members) + } +} + +// TestDirectoryEndpointParity pins the server-side endpoint derivation to the +// cross-language parity vector emitted by cmd/busvectors (and consumed by the +// uniweb crypto.ts endpointID test): for a FIXED sign_pub the directory must +// return the exact base64url(sha256(signPub)) endpoint, byte-for-byte. The +// expected value is recomputed here independently of frame.EndpointID so the test +// fails if the handler ever diverges from the canonical construction. +func TestDirectoryEndpointParity(t *testing.T) { + // Vector from cmd/busvectors (seed 000102..1f -> Ed25519 public key). + const vectorSignPub = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" + const vectorEndpoint = "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw" + + // Independent recomputation: base64url(sha256(raw signPub bytes)), unpadded. + raw, err := hex.DecodeString(vectorSignPub) + if err != nil { + t.Fatalf("decode vector sign_pub: %v", err) + } + sum := sha256.Sum256(raw) + if got := base64.RawURLEncoding.EncodeToString(sum[:]); got != vectorEndpoint { + t.Fatalf("vector self-check: recomputed endpoint %q != pinned %q", got, vectorEndpoint) + } + + h := newAuthHarness(t, AuthEnforce) + if err := h.store.AddUser(vectorSignPub, "vectorbot", RoleMember); err != nil { + t.Fatalf("add vector user: %v", err) + } + + code, resp := directory(t, h, h.alice, 1) + if code != http.StatusOK { + t.Fatalf("directory should be 200, got %d", code) + } + row, ok := findMember(resp.Members, vectorSignPub) + if !ok { + t.Fatalf("vector user missing from directory: %+v", resp.Members) + } + if row.Endpoint != vectorEndpoint { + t.Fatalf("endpoint parity broken: directory returned %q, want %q", row.Endpoint, vectorEndpoint) + } +} diff --git a/pkg/membership/server.go b/pkg/membership/server.go index bd7b7f4e..8d39d877 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -3,6 +3,7 @@ package membership import ( "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -414,6 +415,12 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /users", s.handleListUsers) s.mux.HandleFunc("POST /users", s.handleAddUser) s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser) + // Member directory — any authenticated bus user (member or admin) may map an + // endpoint id back to its human handle, so clients can render readable sender + // names instead of raw endpoint hashes. Unlike /users it is NOT admin-only and + // returns only active users; under enforce the auth middleware already rejects + // an unauthenticated caller with 401 before this handler runs (uniweb/0002). + s.mux.HandleFunc("GET /api/directory", s.handleDirectory) } // ---- wire types ----------------------------------------------------------- @@ -512,6 +519,24 @@ type addUserReq struct { Role string `json:"role"` } +// directoryMember is one entry of the GET /api/directory response: enough for a +// client to map a message's endpoint id (which the bus stamps on every frame) +// back to a readable handle. endpoint is derived server-side from sign_pub with +// the SAME construction the bus uses (frame.EndpointID = base64url(sha256(signPub)), +// unpadded), so it matches the sender id a client already has byte-for-byte. +type directoryMember struct { + SignPub string `json:"sign_pub"` + Endpoint string `json:"endpoint"` + Handle string `json:"handle"` + Role string `json:"role"` +} + +// directoryResp is the GET /api/directory response envelope. The members key is a +// stable contract consumed by the browser client; do not rename it. +type directoryResp struct { + Members []directoryMember `json:"members"` +} + // ---- helpers -------------------------------------------------------------- func writeJSON(w http.ResponseWriter, code int, v any) { @@ -857,6 +882,41 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +// handleDirectory returns the active bus user directory so a client can resolve a +// sender's endpoint id to a readable handle. Unlike handleListUsers it is NOT +// admin-only: every authenticated bus user may read it (the auth middleware has +// already verified the caller is an active user under enforce, and rejected an +// unauthenticated one with 401). Only active users are listed, and each endpoint +// is computed server-side from the user's sign_pub with frame.EndpointID — the +// exact derivation the bus stamps on every frame, so the returned endpoint matches +// the sender id a client already holds. A user with a malformed sign_pub (which +// the add path rejects, so this is defensive) is skipped rather than failing the +// whole listing. +func (s *Server) handleDirectory(w http.ResponseWriter, r *http.Request) { + users, err := s.store.ListUsers() + if err != nil { + writeServerErr(w, r, http.StatusInternalServerError, "internal error", err) + return + } + out := make([]directoryMember, 0, len(users)) + for _, u := range users { + if u.Status != StatusActive { + continue + } + signPub, err := hex.DecodeString(u.SignPub) + if err != nil || len(signPub) != 32 { + continue + } + out = append(out, directoryMember{ + SignPub: u.SignPub, + Endpoint: frame.EndpointID(signPub), + Handle: u.Handle, + Role: u.Role, + }) + } + writeJSON(w, http.StatusOK, directoryResp{Members: out}) +} + // handleAddUser registers a new bus user from an admin-supplied Ed25519 signing // key. It mirrors the `membershipd user add` CLI: the key must be 64-hex, the // role must be admin or member (empty defaults to member), and re-adding an