D write-only lockdown (DEC-654): public HTTP server needs the caller's own Verae key/OAuth bearer for stamp (any scope), signup, create_user, session; login refused; no host-login fallback for writes; anonymous verify/hash_report kept, rate limited 30/min per client (burst 15), 600/min overall

This commit is contained in:
George Lambert 2026-10-10 13:06:57 -04:00
commit 10c8634a53
6 changed files with 427 additions and 5 deletions

View file

@ -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)

View 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)
}
}

View file

@ -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.

View file

@ -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
}

174
internal/tools/public.go Normal file
View 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)
}

View file

@ -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/<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.
func (c *Client) Session(ctx context.Context, bearer string) (SessionResult, error) {
if strings.TrimSpace(bearer) == "" {