feat(membership): authorize room reads by membership, not registration
Audit H3 (Alto). 'Authorized' meant 'registered in the allowlist', not 'member
of the room', so any registered peer could read another room's subject, its
full member list (every member's sign_pub + kex_pub), any endpoint's room
directory, and even another member's sealed key.
The middleware now carries the authenticated signer's endpoint id into the
handler via request context. Room handlers enforce membership:
- GET /rooms/{id} and /rooms/{id}/members require the signer to be a member;
- GET /rooms/{id}/key serves the sealed key only to its own endpoint
(endpoint == signer) and only to a member;
- GET /members/{endpoint}/rooms is restricted to the signer's own endpoint.
Authorization is skipped only when no authenticated signer is present (AuthOff
dev, or a soft-mode pass-through), preserving legacy/dev behavior. Internal
errors no longer echo store messages to the client on these paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user