b81e5f26f1
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>
207 lines
7.0 KiB
Go
207 lines
7.0 KiB
Go
package membership
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
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
|
|
// with a fresh store + blob store, and seeds one active admin ("alice").
|
|
type authHarness struct {
|
|
ts *httptest.Server
|
|
store *Store
|
|
alice cs.Identity
|
|
alicePub string // hex
|
|
}
|
|
|
|
func newAuthHarness(t *testing.T, mode AuthMode) *authHarness {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
store, err := Open(filepath.Join(dir, "unibus.db"))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
|
|
if err != nil {
|
|
t.Fatalf("open blobs: %v", err)
|
|
}
|
|
alice, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("identity: %v", err)
|
|
}
|
|
alicePub := hex.EncodeToString(alice.SignPub)
|
|
if err := store.AddUser(alicePub, "alice", RoleAdmin); err != nil {
|
|
t.Fatalf("seed admin: %v", err)
|
|
}
|
|
srv := NewServer(store, blobs, mode)
|
|
ts := httptest.NewServer(srv)
|
|
t.Cleanup(func() {
|
|
ts.Close()
|
|
store.Close()
|
|
})
|
|
return &authHarness{ts: ts, store: store, alice: alice, alicePub: alicePub}
|
|
}
|
|
|
|
// signedReq builds a control-plane request signed by id, with explicit ts/nonce
|
|
// so tests can force skew and replay. It signs via the same CanonicalRequest the
|
|
// production client uses, so the test verifies the real wire contract.
|
|
func signedReq(t *testing.T, base, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request {
|
|
t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
rdr = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequest(method, base+path, rdr)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
tss := strconv.FormatInt(ts, 10)
|
|
canonical := CanonicalRequest(method, path, tss, nonce, body)
|
|
sig := cs.SignEd25519(id.SignPriv, canonical)
|
|
req.Header.Set(hdrPub, hex.EncodeToString(id.SignPub))
|
|
req.Header.Set(hdrTs, tss)
|
|
req.Header.Set(hdrNonce, nonce)
|
|
req.Header.Set(hdrSig, base64.StdEncoding.EncodeToString(sig))
|
|
return req
|
|
}
|
|
|
|
func do(t *testing.T, req *http.Request) (int, string) {
|
|
t.Helper()
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(b)
|
|
}
|
|
|
|
// 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", aliceRoomsPath(h), nil, h.alice, now, "nonce-golden"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("golden signed request should be 200, got %d", code)
|
|
}
|
|
}
|
|
|
|
// Error path: a structurally valid signature from an identity that is NOT in the
|
|
// allowlist is rejected with 401.
|
|
func TestAuthUnregisteredRejected(t *testing.T) {
|
|
h := newAuthHarness(t, AuthEnforce)
|
|
bob, _ := cs.GenerateIdentity()
|
|
now := time.Now().Unix()
|
|
code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, bob, now, "nonce-bob"))
|
|
if code != http.StatusUnauthorized {
|
|
t.Fatalf("unregistered identity should be 401, got %d (%s)", code, body)
|
|
}
|
|
}
|
|
|
|
// Error path: replaying a captured request (same nonce + signature) is rejected.
|
|
func TestAuthReplayRejected(t *testing.T) {
|
|
h := newAuthHarness(t, AuthEnforce)
|
|
now := time.Now().Unix()
|
|
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", 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)
|
|
}
|
|
}
|
|
|
|
// Error path: a timestamp outside the ±30s window is rejected even with a valid
|
|
// signature (defends against long-delayed captured requests).
|
|
func TestAuthClockSkewRejected(t *testing.T) {
|
|
h := newAuthHarness(t, AuthEnforce)
|
|
stale := time.Now().Unix() - 120
|
|
code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, stale, "nonce-skew"))
|
|
if code != http.StatusUnauthorized {
|
|
t.Fatalf("clock-skewed request should be 401, got %d (%s)", code, body)
|
|
}
|
|
}
|
|
|
|
// Error path: tampering the body after signing invalidates the signature.
|
|
func TestAuthTamperedBodyRejected(t *testing.T) {
|
|
h := newAuthHarness(t, AuthEnforce)
|
|
now := time.Now().Unix()
|
|
req := signedReq(t, h.ts.URL, "POST", "/rooms", []byte(`{"subject":"x"}`), h.alice, now, "nonce-tamper")
|
|
// Swap the body for different bytes the signature does not cover.
|
|
req.Body = io.NopCloser(bytes.NewReader([]byte(`{"subject":"evil"}`)))
|
|
req.ContentLength = int64(len(`{"subject":"evil"}`))
|
|
code, body := do(t, req)
|
|
if code != http.StatusUnauthorized {
|
|
t.Fatalf("tampered body should be 401, got %d (%s)", code, body)
|
|
}
|
|
}
|
|
|
|
// Error path: missing auth headers under enforce are rejected.
|
|
func TestAuthMissingHeadersRejected(t *testing.T) {
|
|
h := newAuthHarness(t, AuthEnforce)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil)
|
|
code, _ := do(t, req)
|
|
if code != http.StatusUnauthorized {
|
|
t.Fatalf("unsigned request under enforce should be 401, got %d", code)
|
|
}
|
|
}
|
|
|
|
// Exemption: the health probe bypasses auth even under enforce.
|
|
func TestAuthHealthExempt(t *testing.T) {
|
|
h := newAuthHarness(t, AuthEnforce)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/healthz", nil)
|
|
code, _ := do(t, req)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("/healthz must be reachable without auth, got %d", code)
|
|
}
|
|
}
|
|
|
|
// Soft mode: an unauthenticated request is logged but allowed through, so
|
|
// clients can migrate without an outage.
|
|
func TestAuthSoftAllowsUnauthenticated(t *testing.T) {
|
|
h := newAuthHarness(t, AuthSoft)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil)
|
|
code, _ := do(t, req)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("soft mode should allow unsigned request, got %d", code)
|
|
}
|
|
}
|
|
|
|
// Off mode (default for legacy callers): no verification at all.
|
|
func TestAuthOffNoVerification(t *testing.T) {
|
|
h := newAuthHarness(t, AuthOff)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil)
|
|
code, _ := do(t, req)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("off mode should allow unsigned request, got %d", code)
|
|
}
|
|
}
|