Merge d-write-lockdown (DEC-655): live on mcp.verae.ai since 2026-10-10 13:08 ET (binary built from 10c8634)
This commit is contained in:
commit
e24d466f2b
6 changed files with 427 additions and 5 deletions
|
|
@ -61,6 +61,8 @@ func main() {
|
||||||
// (Mcp-Session-Id after initialize). Stateless broke tools/call for those clients.
|
// (Mcp-Session-Id after initialize). Stateless broke tools/call for those clients.
|
||||||
JSONResponse: true,
|
JSONResponse: true,
|
||||||
Stateless: false,
|
Stateless: false,
|
||||||
|
// DEC-654: the HTTP transport is the public server (mcp.verae.ai) unless VERAE_MCP_TRUSTED_LOCAL=1.
|
||||||
|
Public: os.Getenv("VERAE_MCP_TRUSTED_LOCAL") != "1",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("server: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
|
|
|
||||||
199
internal/server/public_test.go
Normal file
199
internal/server/public_test.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
|
||||||
|
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeVT records the bearer of every upstream call. "good-key" is a valid credential; the host login is "host-jwt".
|
||||||
|
type fakeVT struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
stamps atomic.Int32
|
||||||
|
hostUse atomic.Int32
|
||||||
|
users atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeVT(t *testing.T) *fakeVT {
|
||||||
|
f := &fakeVT{}
|
||||||
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "Bearer host-jwt" {
|
||||||
|
f.hostUse.Add(1)
|
||||||
|
}
|
||||||
|
ok := auth == "Bearer good-key" || auth == "Bearer host-jwt"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/auth/login"):
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"token": "host-jwt", "username": "svc", "role": "admin", "expires_at": "2099-01-01T00:00:00Z"})
|
||||||
|
case !ok:
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"})
|
||||||
|
case r.URL.Path == "/api/status/mcp-credential-check":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"error": "job not found"})
|
||||||
|
case r.URL.Path == "/api/timestamp":
|
||||||
|
f.stamps.Add(1)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"job_id": "j1", "status": "completed", "certificate": "QmTestCid", "merkle_root": "ab", "created_at": "2026-10-10T00:00:00Z"})
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/status/"):
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"job_id": "j1", "status": "completed", "certificate": "QmTestCid", "merkle_root": "ab"})
|
||||||
|
case r.URL.Path == "/api/verify":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"valid": true, "digest": strings.Repeat("a", 64)})
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/hash/"):
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"receipts": []any{}})
|
||||||
|
case r.URL.Path == "/auth/validate":
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"valid": true, "username": "who", "role": "user"})
|
||||||
|
case r.URL.Path == "/auth/users", r.URL.Path == "/auth/signup":
|
||||||
|
f.users.Add(1)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.srv.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicSession(t *testing.T, vt *fakeVT, bearer, xff string) *mcp.ClientSession {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
led, err := ledger.Open(filepath.Join(dir, "ledger.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authPath := filepath.Join(dir, "auth.properties")
|
||||||
|
if err := os.WriteFile(authPath, []byte("username=svc\npassword=pw\ntoken=host-jwt\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("VERAE_GLOBAL_TOKEN", "host-jwt")
|
||||||
|
srv, err := server.New(server.Options{Ledger: led, AuthPath: authPath, GlobalURL: vt.srv.URL, JSONResponse: true, Public: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := srv.Handler()
|
||||||
|
hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if bearer != "" {
|
||||||
|
r.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
}
|
||||||
|
if xff != "" {
|
||||||
|
r.Header.Set("X-Forwarded-For", xff)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(hs.Close)
|
||||||
|
c := mcp.NewClient(&mcp.Implementation{Name: "t", Version: "0"}, nil)
|
||||||
|
s, err := c.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) (bool, string) {
|
||||||
|
t.Helper()
|
||||||
|
res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(res)
|
||||||
|
return !res.IsError, string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dig = strings.Repeat("a", 64)
|
||||||
|
|
||||||
|
func TestPublicAnonymousWritesRefusedNoHostFallback(t *testing.T) {
|
||||||
|
vt := newFakeVT(t)
|
||||||
|
s := publicSession(t, vt, "", "203.0.113.5")
|
||||||
|
for _, c := range []struct {
|
||||||
|
tool string
|
||||||
|
args map[string]any
|
||||||
|
}{
|
||||||
|
{"stamp", map[string]any{"sha256": dig, "scope": "global"}},
|
||||||
|
{"stamp", map[string]any{"sha256": dig}}, // local ledger on the server is a write too
|
||||||
|
{"create_user", map[string]any{"username": "evil", "password": "pw123456", "role": "admin"}},
|
||||||
|
{"signup", map[string]any{"username": "spam", "password": "pw123456"}},
|
||||||
|
{"login", map[string]any{"username": "admin", "password": "x"}},
|
||||||
|
} {
|
||||||
|
if ok, out := call(t, s, c.tool, c.args); ok {
|
||||||
|
t.Fatalf("%s anonymous succeeded: %s", c.tool, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok, out := call(t, s, "session", map[string]any{})
|
||||||
|
if !ok || strings.Contains(out, `"valid":true`) {
|
||||||
|
t.Fatalf("anonymous session must not report the host login: %v %s", ok, out)
|
||||||
|
}
|
||||||
|
if vt.stamps.Load() != 0 || vt.users.Load() != 0 || vt.hostUse.Load() != 0 {
|
||||||
|
t.Fatalf("upstream writes or host-token use: stamps=%d users=%d host=%d", vt.stamps.Load(), vt.users.Load(), vt.hostUse.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicBadKeyRefused(t *testing.T) {
|
||||||
|
vt := newFakeVT(t)
|
||||||
|
s := publicSession(t, vt, "xVerae-wrong", "203.0.113.6")
|
||||||
|
if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig, "scope": "global"}); ok {
|
||||||
|
t.Fatalf("bad key stamped: %s", out)
|
||||||
|
}
|
||||||
|
if ok, _ := call(t, s, "stamp", map[string]any{"sha256": dig}); ok {
|
||||||
|
t.Fatal("bad key wrote the local ledger")
|
||||||
|
}
|
||||||
|
if vt.stamps.Load() != 0 || vt.hostUse.Load() != 0 {
|
||||||
|
t.Fatalf("stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicKeyedWritesUseCallerCredential(t *testing.T) {
|
||||||
|
vt := newFakeVT(t)
|
||||||
|
s := publicSession(t, vt, "good-key", "203.0.113.7")
|
||||||
|
if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig, "scope": "global"}); !ok {
|
||||||
|
t.Fatalf("keyed global stamp: %s", out)
|
||||||
|
}
|
||||||
|
if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig}); !ok {
|
||||||
|
t.Fatalf("keyed local stamp: %s", out)
|
||||||
|
}
|
||||||
|
if ok, out := call(t, s, "session", map[string]any{}); !ok || !strings.Contains(out, `"valid":true`) {
|
||||||
|
t.Fatalf("keyed session: %s", out)
|
||||||
|
}
|
||||||
|
if ok, _ := call(t, s, "login", map[string]any{"username": "a", "password": "b"}); ok {
|
||||||
|
t.Fatal("login must stay disabled on the public server")
|
||||||
|
}
|
||||||
|
if vt.stamps.Load() != 1 || vt.hostUse.Load() != 0 {
|
||||||
|
t.Fatalf("stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicAnonymousReadsAllowedAndRateLimited(t *testing.T) {
|
||||||
|
vt := newFakeVT(t)
|
||||||
|
s := publicSession(t, vt, "", "198.51.100.9")
|
||||||
|
if ok, out := call(t, s, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok {
|
||||||
|
t.Fatalf("anonymous verify: %s", out)
|
||||||
|
}
|
||||||
|
if ok, out := call(t, s, "hash_report", map[string]any{"sha256": dig, "want_scope": "global"}); !ok {
|
||||||
|
t.Fatalf("anonymous hash_report: %s", out)
|
||||||
|
}
|
||||||
|
limited := false
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
if ok, out := call(t, s, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok && strings.Contains(out, "too many") {
|
||||||
|
limited = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !limited {
|
||||||
|
t.Fatal("anonymous reads were not rate limited")
|
||||||
|
}
|
||||||
|
// another client is unaffected
|
||||||
|
s2 := publicSession(t, vt, "", "198.51.100.10")
|
||||||
|
if ok, out := call(t, s2, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok {
|
||||||
|
t.Fatalf("other client limited: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ type Options struct {
|
||||||
RequireBearer bool
|
RequireBearer bool
|
||||||
JSONResponse bool
|
JSONResponse bool
|
||||||
Stateless bool
|
Stateless bool
|
||||||
|
// Public (DEC-654): writes need the caller's own credential; anonymous verify/hash_report only, rate limited.
|
||||||
|
Public bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server wraps the MCP Streamable HTTP handler mounted at /mcp.
|
// Server wraps the MCP Streamable HTTP handler mounted at /mcp.
|
||||||
|
|
@ -66,6 +68,9 @@ func New(opts Options) (*Server, error) {
|
||||||
Keeper: keeper,
|
Keeper: keeper,
|
||||||
RequireBearer: opts.RequireBearer,
|
RequireBearer: opts.RequireBearer,
|
||||||
}
|
}
|
||||||
|
if opts.Public {
|
||||||
|
deps.Public, deps.Checker, deps.Limiter = true, global, tools.NewLimiter()
|
||||||
|
}
|
||||||
mcpServer := tools.NewServer(deps)
|
mcpServer := tools.NewServer(deps)
|
||||||
// Behind Caddy (or any reverse proxy) the bind is loopback while Host is
|
// Behind Caddy (or any reverse proxy) the bind is loopback while Host is
|
||||||
// the public name (mcp.verae.ai). Disable the SDK's localhost Host check.
|
// the public name (mcp.verae.ai). Disable the SDK's localhost Host check.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ type Deps struct {
|
||||||
Global *veraetime.Client
|
Global *veraetime.Client
|
||||||
Keeper *veraetime.Keeper
|
Keeper *veraetime.Keeper
|
||||||
RequireBearer bool
|
RequireBearer bool
|
||||||
|
// Public: the public HTTP server (DEC-654): writes need the caller's own credential, anonymous reads are limited.
|
||||||
|
Public bool
|
||||||
|
Checker CredentialChecker
|
||||||
|
Limiter *Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||||
|
|
@ -31,12 +35,17 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
|
||||||
if scope == "" {
|
if scope == "" {
|
||||||
scope = "local"
|
scope = "local"
|
||||||
}
|
}
|
||||||
|
if d.Public {
|
||||||
|
if _, err := d.writeBearer(ctx, req); err != nil {
|
||||||
|
return nil, ReceiptsOut{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
switch scope {
|
switch scope {
|
||||||
case "global":
|
case "global":
|
||||||
if d.Global == nil {
|
if d.Global == nil {
|
||||||
return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured")
|
return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured")
|
||||||
}
|
}
|
||||||
tok, err := d.resolveBearer(ctx, req)
|
tok, err := d.writeBearer(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ReceiptsOut{}, err
|
return nil, ReceiptsOut{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +104,7 @@ func (d *Deps) verifyGlobal(ctx context.Context, req *mcp.CallToolRequest, recei
|
||||||
if d.Global == nil {
|
if d.Global == nil {
|
||||||
return nil, VerifyOut{Valid: false, Error: "global VeraeTime client not configured"}, nil
|
return nil, VerifyOut{Valid: false, Error: "global VeraeTime client not configured"}, nil
|
||||||
}
|
}
|
||||||
tok, err := d.resolveBearer(ctx, req)
|
tok, err := d.readBearer(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, VerifyOut{}, err
|
return nil, VerifyOut{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +166,7 @@ func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolReques
|
||||||
if d.Global == nil {
|
if d.Global == nil {
|
||||||
return nil, fmt.Errorf("global VeraeTime client not configured")
|
return nil, fmt.Errorf("global VeraeTime client not configured")
|
||||||
}
|
}
|
||||||
tok, err := d.resolveBearer(ctx, req)
|
tok, err := d.readBearer(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -172,10 +181,13 @@ func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolReques
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deps) signup(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) {
|
func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) {
|
||||||
if d.Global == nil {
|
if d.Global == nil {
|
||||||
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
|
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
|
||||||
}
|
}
|
||||||
|
if _, err := d.writeBearer(ctx, req); d.Public && err != nil {
|
||||||
|
return nil, CreateUserOut{}, err
|
||||||
|
}
|
||||||
res, err := d.Global.Signup(ctx, in.Username, in.Password)
|
res, err := d.Global.Signup(ctx, in.Username, in.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, CreateUserOut{}, err
|
return nil, CreateUserOut{}, err
|
||||||
|
|
@ -184,6 +196,9 @@ func (d *Deps) signup(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPa
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deps) login(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, LoginOut, error) {
|
func (d *Deps) login(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, LoginOut, error) {
|
||||||
|
if d.Public {
|
||||||
|
return nil, LoginOut{}, ErrLoginDisabled
|
||||||
|
}
|
||||||
if d.Global == nil {
|
if d.Global == nil {
|
||||||
return nil, LoginOut{}, fmt.Errorf("global VeraeTime client not configured")
|
return nil, LoginOut{}, fmt.Errorf("global VeraeTime client not configured")
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +214,9 @@ func (d *Deps) session(ctx context.Context, req *mcp.CallToolRequest, _ EmptyArg
|
||||||
return nil, SessionOut{}, fmt.Errorf("global VeraeTime client not configured")
|
return nil, SessionOut{}, fmt.Errorf("global VeraeTime client not configured")
|
||||||
}
|
}
|
||||||
tok, err := d.resolveBearer(ctx, req)
|
tok, err := d.resolveBearer(ctx, req)
|
||||||
|
if d.Public && BearerFromRequest(req) == "" {
|
||||||
|
err = ErrWriteNeedsKey // never report the host's own login to an anonymous caller
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, SessionOut{Valid: false}, nil
|
return nil, SessionOut{Valid: false}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +236,7 @@ func (d *Deps) createUser(ctx context.Context, req *mcp.CallToolRequest, in Crea
|
||||||
if d.Global == nil {
|
if d.Global == nil {
|
||||||
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
|
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
|
||||||
}
|
}
|
||||||
tok, err := d.resolveBearer(ctx, req)
|
tok, err := d.writeBearer(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, CreateUserOut{}, err
|
return nil, CreateUserOut{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
internal/tools/public.go
Normal file
174
internal/tools/public.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
// DEC-654 (D): on the public HTTP server (mcp.verae.ai) every write needs the caller's own credential - a Verae API
|
||||||
|
// key or a VeraeTime login/OAuth bearer, checked at api.veraetime.net - and never falls back to this host's stored
|
||||||
|
// service login. Writes: stamp (any scope; global stamps are billed to the key's customer), signup, create_user,
|
||||||
|
// session; login (it would replace the host credential) is refused. Read-only verify and hash_report stay
|
||||||
|
// anonymous for the public pages (receipt links, explorer); only those use the host login, rate limited per client.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWriteNeedsKey is returned for an anonymous write on the public server.
|
||||||
|
var ErrWriteNeedsKey = errors.New("this tool needs your own Verae credential: send Authorization: Bearer <Verae API key> " +
|
||||||
|
"(create one at https://zap.verae.ai/portal/ or run `verae login`)")
|
||||||
|
|
||||||
|
// ErrLoginDisabled is returned by the login tool on the public server.
|
||||||
|
var ErrLoginDisabled = errors.New("login is not available on the public server; sign in at https://zap.verae.ai/portal/ " +
|
||||||
|
"(or `verae login`) and send the Verae API key as a bearer token")
|
||||||
|
|
||||||
|
// ErrRateLimited is returned when anonymous reads exceed the per-client budget.
|
||||||
|
var ErrRateLimited = errors.New("too many anonymous lookups; retry in a minute or send a Verae API key")
|
||||||
|
|
||||||
|
// CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential).
|
||||||
|
type CredentialChecker interface {
|
||||||
|
CheckCredential(ctx context.Context, bearer string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBearer returns the caller's own validated bearer; on the public server there is no host fallback.
|
||||||
|
func (d *Deps) writeBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||||
|
if !d.Public {
|
||||||
|
return d.resolveBearer(ctx, req)
|
||||||
|
}
|
||||||
|
tok := BearerFromRequest(req)
|
||||||
|
if tok == "" {
|
||||||
|
return "", ErrWriteNeedsKey
|
||||||
|
}
|
||||||
|
if err := d.checkCred(ctx, tok); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBearer is the caller's bearer, or (anonymous, rate limited) the host login for read-only lookups.
|
||||||
|
func (d *Deps) readBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||||
|
if d.Public && BearerFromRequest(req) == "" {
|
||||||
|
if d.Limiter != nil && !d.Limiter.Allow(clientKey(req)) {
|
||||||
|
return "", ErrRateLimited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d.resolveBearer(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type credEntry struct {
|
||||||
|
ok bool
|
||||||
|
exp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
credMu sync.Mutex
|
||||||
|
credCache = map[[32]byte]credEntry{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *Deps) checkCred(ctx context.Context, tok string) error {
|
||||||
|
if d.Checker == nil {
|
||||||
|
return ErrWriteNeedsKey // fail closed
|
||||||
|
}
|
||||||
|
h := sha256.Sum256([]byte(tok))
|
||||||
|
credMu.Lock()
|
||||||
|
e, hit := credCache[h]
|
||||||
|
credMu.Unlock()
|
||||||
|
if hit && time.Now().Before(e.exp) {
|
||||||
|
if e.ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("the Verae credential was not accepted")
|
||||||
|
}
|
||||||
|
err := d.Checker.CheckCredential(ctx, tok)
|
||||||
|
var bad *veraetime.CredentialRejected
|
||||||
|
if err != nil && !errors.As(err, &bad) {
|
||||||
|
return err // upstream unavailable: not cached, fail closed
|
||||||
|
}
|
||||||
|
credMu.Lock()
|
||||||
|
if len(credCache) > 10000 {
|
||||||
|
credCache = map[[32]byte]credEntry{}
|
||||||
|
}
|
||||||
|
credCache[h] = credEntry{ok: err == nil, exp: time.Now().Add(60 * time.Second)}
|
||||||
|
credMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("the Verae credential was not accepted")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientKey is the client address Caddy put last in X-Forwarded-For (Caddy overwrites untrusted values).
|
||||||
|
func clientKey(req *mcp.CallToolRequest) string {
|
||||||
|
if req == nil || req.Extra == nil || req.Extra.Header == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
hd := http.Header(req.Extra.Header)
|
||||||
|
if xff := hd.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
return strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
if ip := strings.TrimSpace(hd.Get("X-Real-IP")); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter is a per-client token bucket plus a global cap for anonymous reads.
|
||||||
|
type Limiter struct {
|
||||||
|
PerMinute float64 // per client
|
||||||
|
Burst float64
|
||||||
|
GlobalPerMin float64
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]*bucket
|
||||||
|
global bucket
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens float64
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLimiter: 30/min per client (burst 15), 600/min overall.
|
||||||
|
func NewLimiter() *Limiter {
|
||||||
|
return &Limiter{PerMinute: 30, Burst: 15, GlobalPerMin: 600, buckets: map[string]*bucket{}, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) take(b *bucket, perMin, burst float64, now time.Time) bool {
|
||||||
|
if b.last.IsZero() {
|
||||||
|
b.tokens, b.last = burst, now
|
||||||
|
}
|
||||||
|
b.tokens += now.Sub(b.last).Minutes() * perMin
|
||||||
|
if b.tokens > burst {
|
||||||
|
b.tokens = burst
|
||||||
|
}
|
||||||
|
b.last = now
|
||||||
|
if b.tokens < 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether client may make one more anonymous read now.
|
||||||
|
func (l *Limiter) Allow(client string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.now()
|
||||||
|
if len(l.buckets) > 50000 {
|
||||||
|
l.buckets = map[string]*bucket{}
|
||||||
|
}
|
||||||
|
b := l.buckets[client]
|
||||||
|
if b == nil {
|
||||||
|
b = &bucket{}
|
||||||
|
l.buckets[client] = b
|
||||||
|
}
|
||||||
|
if !l.take(b, l.PerMinute, l.Burst, now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return l.take(&l.global, l.GlobalPerMin, l.GlobalPerMin, now)
|
||||||
|
}
|
||||||
|
|
@ -453,6 +453,30 @@ func (c *Client) authenticate(ctx context.Context, username, password string) (L
|
||||||
}, token, nil
|
}, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckCredential validates a caller bearer (Verae API key or VeraeTime JWT) without side effects: an authenticated
|
||||||
|
// GET /api/status/<probe> answers 401/403 for a bad credential and 404 for a good one (DEC-654).
|
||||||
|
// A definite rejection returns *CredentialRejected (cacheable); anything else is an outage.
|
||||||
|
func (c *Client) CheckCredential(ctx context.Context, bearer string) error {
|
||||||
|
status, _, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/status/mcp-credential-check", nil, bearer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||||
|
return &CredentialRejected{Status: status}
|
||||||
|
}
|
||||||
|
if status >= 500 {
|
||||||
|
return fmt.Errorf("veraetime: credential check unavailable (%d)", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialRejected is a definite "invalid credential" answer.
|
||||||
|
type CredentialRejected struct{ Status int }
|
||||||
|
|
||||||
|
func (e *CredentialRejected) Error() string {
|
||||||
|
return fmt.Sprintf("veraetime: credential rejected (%d)", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
// Session validates a bearer via GET /auth/validate.
|
// Session validates a bearer via GET /auth/validate.
|
||||||
func (c *Client) Session(ctx context.Context, bearer string) (SessionResult, error) {
|
func (c *Client) Session(ctx context.Context, bearer string) (SessionResult, error) {
|
||||||
if strings.TrimSpace(bearer) == "" {
|
if strings.TrimSpace(bearer) == "" {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue