diff --git a/cmd/verae-mcp-timestamping-server/main.go b/cmd/verae-mcp-timestamping-server/main.go index 2de7b76..642d6bc 100644 --- a/cmd/verae-mcp-timestamping-server/main.go +++ b/cmd/verae-mcp-timestamping-server/main.go @@ -61,6 +61,8 @@ func main() { // (Mcp-Session-Id after initialize). Stateless broke tools/call for those clients. JSONResponse: true, 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 { log.Fatalf("server: %v", err) diff --git a/internal/server/public_test.go b/internal/server/public_test.go new file mode 100644 index 0000000..2f1840a --- /dev/null +++ b/internal/server/public_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 275a2f7..8cf01a9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,6 +23,8 @@ type Options struct { RequireBearer bool JSONResponse 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. @@ -66,6 +68,9 @@ func New(opts Options) (*Server, error) { Keeper: keeper, RequireBearer: opts.RequireBearer, } + if opts.Public { + deps.Public, deps.Checker, deps.Limiter = true, global, tools.NewLimiter() + } mcpServer := tools.NewServer(deps) // 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. diff --git a/internal/tools/handlers.go b/internal/tools/handlers.go index 29a7f8c..ab467c3 100644 --- a/internal/tools/handlers.go +++ b/internal/tools/handlers.go @@ -17,6 +17,10 @@ type Deps struct { Global *veraetime.Client Keeper *veraetime.Keeper 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) { @@ -31,12 +35,17 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs if scope == "" { scope = "local" } + if d.Public { + if _, err := d.writeBearer(ctx, req); err != nil { + return nil, ReceiptsOut{}, err + } + } switch scope { case "global": if d.Global == nil { 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 { return nil, ReceiptsOut{}, err } @@ -95,7 +104,7 @@ func (d *Deps) verifyGlobal(ctx context.Context, req *mcp.CallToolRequest, recei if d.Global == 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 { return nil, VerifyOut{}, err } @@ -157,7 +166,7 @@ func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolReques if d.Global == nil { return nil, fmt.Errorf("global VeraeTime client not configured") } - tok, err := d.resolveBearer(ctx, req) + tok, err := d.readBearer(ctx, req) if err != nil { return nil, err } @@ -172,10 +181,13 @@ func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolReques 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 { 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) if err != nil { 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) { + if d.Public { + return nil, LoginOut{}, ErrLoginDisabled + } if d.Global == nil { 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") } 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 { 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 { 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 { return nil, CreateUserOut{}, err } diff --git a/internal/tools/public.go b/internal/tools/public.go new file mode 100644 index 0000000..18e7c16 --- /dev/null +++ b/internal/tools/public.go @@ -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 " + + "(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) +} diff --git a/internal/veraetime/client.go b/internal/veraetime/client.go index 54e10ac..ece7a7c 100644 --- a/internal/veraetime/client.go +++ b/internal/veraetime/client.go @@ -453,6 +453,30 @@ func (c *Client) authenticate(ctx context.Context, username, password string) (L }, token, nil } +// CheckCredential validates a caller bearer (Verae API key or VeraeTime JWT) without side effects: an authenticated +// GET /api/status/ 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. func (c *Client) Session(ctx context.Context, bearer string) (SessionResult, error) { if strings.TrimSpace(bearer) == "" {