From 348ccd0bf27bd9f7ba19f90f5e439132d86db43b Mon Sep 17 00:00:00 2001 From: George Lambert Date: Tue, 29 Sep 2026 11:57:40 -0400 Subject: [PATCH] feat: VeraeTime client, auth.properties, and host-token keeper HTTPS GlobalClient (Stamp/Verify/HashReport/Signup/Login/Session/CreateUser) with Fake RoundTripper tests; auth.properties read/write; Keeper refresh ~60s before expiry or VERAE_GLOBAL_TOKEN; authctx.Resolve for require-bearer vs host fallback. --- internal/authctx/resolve.go | 27 ++ internal/authctx/resolve_test.go | 117 ++++++ internal/veraetime/authprops.go | 137 +++++++ internal/veraetime/client.go | 592 ++++++++++++++++++++++++++++++ internal/veraetime/client_test.go | 407 ++++++++++++++++++++ internal/veraetime/keeper.go | 144 ++++++++ 6 files changed, 1424 insertions(+) create mode 100644 internal/authctx/resolve.go create mode 100644 internal/authctx/resolve_test.go create mode 100644 internal/veraetime/authprops.go create mode 100644 internal/veraetime/client.go create mode 100644 internal/veraetime/client_test.go create mode 100644 internal/veraetime/keeper.go diff --git a/internal/authctx/resolve.go b/internal/authctx/resolve.go new file mode 100644 index 0000000..b498ea3 --- /dev/null +++ b/internal/authctx/resolve.go @@ -0,0 +1,27 @@ +package authctx + +import ( + "context" + "fmt" + "strings" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" +) + +// Resolve picks the bearer for VeraeTime calls. +// Caller Bearer wins when present. When requireBearer is true, an empty +// caller bearer is an error (no host fallback). When false (default), falls +// back to Keeper host token (VERAE_GLOBAL_TOKEN / auth.properties / login). +func Resolve(ctx context.Context, reqBearer string, requireBearer bool, k *veraetime.Keeper) (string, error) { + tok := strings.TrimSpace(reqBearer) + if tok != "" { + return tok, nil + } + if requireBearer { + return "", fmt.Errorf("authctx: bearer required (VERAE_MCP_REQUIRE_BEARER=1)") + } + if k == nil { + return "", fmt.Errorf("authctx: no bearer and nil host-token keeper") + } + return k.Token(ctx) +} diff --git a/internal/authctx/resolve_test.go b/internal/authctx/resolve_test.go new file mode 100644 index 0000000..d8716e0 --- /dev/null +++ b/internal/authctx/resolve_test.go @@ -0,0 +1,117 @@ +package authctx_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx" + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func jsonResp(status int, body any) *http.Response { + raw, _ := json.Marshal(body) + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(raw)), + } +} + +func TestResolve_PreferCallerBearer(t *testing.T) { + k := veraetime.NewKeeper(veraetime.NewClient("https://api.example"), "") + tok, err := authctx.Resolve(context.Background(), "caller-jwt", false, k) + if err != nil { + t.Fatal(err) + } + if tok != "caller-jwt" { + t.Fatalf("tok=%q", tok) + } +} + +func TestResolve_RequireBearerRejectsEmpty(t *testing.T) { + dir := t.TempDir() + path := dir + "/auth.properties" + _ = veraetime.WriteProperties(path, map[string]string{ + "access_token": "host-token", + "expires_at": "2099-01-01T00:00:00Z", + "username": "admin", + "password": "x", + }) + k := veraetime.NewKeeper(veraetime.NewClient("https://api.example"), path) + _, err := authctx.Resolve(context.Background(), "", true, k) + if err == nil { + t.Fatal("expected error when require-bearer and empty") + } + if !strings.Contains(err.Error(), "bearer") { + t.Fatalf("err=%v", err) + } +} + +func TestResolve_HostFallbackWhenNotRequired(t *testing.T) { + dir := t.TempDir() + path := dir + "/auth.properties" + if err := veraetime.WriteProperties(path, map[string]string{ + "access_token": "host-token", + "expires_at": "2099-01-01T00:00:00Z", + "username": "admin", + "password": "x", + }); err != nil { + t.Fatal(err) + } + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP") + return nil, nil + }))) + k := veraetime.NewKeeper(c, path) + tok, err := authctx.Resolve(context.Background(), "", false, k) + if err != nil { + t.Fatal(err) + } + if tok != "host-token" { + t.Fatalf("tok=%q", tok) + } +} + +func TestResolve_HostFallbackRefresh(t *testing.T) { + dir := t.TempDir() + path := dir + "/auth.properties" + if err := veraetime.WriteProperties(path, map[string]string{ + "access_token": "old", + "expires_at": "2020-01-01T00:00:00Z", + "username": "admin", + "password": "secret-pass", + }); err != nil { + t.Fatal(err) + } + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/auth/login") { + t.Fatalf("path=%s", r.URL.Path) + } + return jsonResp(200, map[string]any{ + "token": "fresh-token", + "expiresAt": "2099-01-01T00:00:00Z", + "user": map[string]any{"username": "admin", "role": "admin"}, + }), nil + }) + c := veraetime.NewClient("https://api.example", + veraetime.WithRoundTripper(rt), + veraetime.WithAuthPath(path), + ) + k := veraetime.NewKeeper(c, path) + tok, err := authctx.Resolve(context.Background(), " ", false, k) + if err != nil { + t.Fatal(err) + } + if tok != "fresh-token" { + t.Fatalf("tok=%q", tok) + } +} diff --git a/internal/veraetime/authprops.go b/internal/veraetime/authprops.go new file mode 100644 index 0000000..d8de114 --- /dev/null +++ b/internal/veraetime/authprops.go @@ -0,0 +1,137 @@ +package veraetime + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// DefaultBaseURL is the production VeraeTime API. +const DefaultBaseURL = "https://api.veraetime.net" + +// TokenRefreshSkew is how far before expires_at a host token is refreshed. +const TokenRefreshSkew = 60 * time.Second + +var expiryRe = regexp.MustCompile(`^(.*T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2}|Z)?$`) + +// DefaultAuthPath returns VERAE_AUTH_PROPERTIES or $HOME/.verae/auth.properties. +func DefaultAuthPath() string { + if v := strings.TrimSpace(os.Getenv("VERAE_AUTH_PROPERTIES")); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(".verae", "auth.properties") + } + return filepath.Join(home, ".verae", "auth.properties") +} + +// ReadProperties loads key=value lines from path. Missing file yields empty map. +func ReadProperties(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + defer f.Close() + props := make(map[string]string) + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + key, val, _ := strings.Cut(line, "=") + props[strings.TrimSpace(key)] = strings.TrimSpace(val) + } + return props, sc.Err() +} + +// WriteProperties merges props into path (mode 0600); parent dir 0700. +func WriteProperties(path string, props map[string]string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + merged, err := ReadProperties(path) + if err != nil { + return err + } + for k, v := range props { + merged[k] = v + } + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + for k, v := range merged { + if _, err := fmt.Fprintf(f, "%s=%s\n", k, v); err != nil { + f.Close() + _ = os.Remove(tmp) + return err + } + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + _ = os.Chmod(tmp, 0o600) + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + _ = os.Chmod(path, 0o600) + return nil +} + +// ParseExpiry parses VeraeTime expiresAt / expires_at strings. +func ParseExpiry(value string) (time.Time, bool) { + text := strings.TrimSpace(value) + if text == "" { + return time.Time{}, false + } + if strings.HasSuffix(text, "Z") { + text = text[:len(text)-1] + "+00:00" + } + m := expiryRe.FindStringSubmatch(text) + if m == nil { + t, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) + if err != nil { + return time.Time{}, false + } + return t.UTC(), true + } + frac := m[2] + if frac != "" { + if len(frac) > 7 { + frac = frac[:7] + } + } + zone := m[3] + if zone == "" || zone == "Z" { + zone = "+00:00" + } + parsed, err := time.Parse("2006-01-02T15:04:05.999999-07:00", m[1]+frac+zone) + if err != nil { + parsed, err = time.Parse("2006-01-02T15:04:05-07:00", m[1]+zone) + if err != nil { + return time.Time{}, false + } + } + return parsed.UTC(), true +} + +func storedAccessToken(props map[string]string) string { + for _, k := range []string{"access_token", "id_token", "auth_token"} { + if v := strings.TrimSpace(props[k]); v != "" { + return v + } + } + return "" +} diff --git a/internal/veraetime/client.go b/internal/veraetime/client.go new file mode 100644 index 0000000..54e10ac --- /dev/null +++ b/internal/veraetime/client.go @@ -0,0 +1,592 @@ +package veraetime + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest" +) + +const ( + defaultPollInterval = 250 * time.Millisecond + defaultMaxPolls = 40 +) + +// AuthError is returned for HTTP 401 from VeraeTime. +type AuthError struct { + Status int + Message string +} + +func (e *AuthError) Error() string { + if e.Message != "" { + return fmt.Sprintf("veraetime: auth HTTP %d: %s", e.Status, e.Message) + } + return fmt.Sprintf("veraetime: auth HTTP %d", e.Status) +} + +// Receipt is a global timestamp receipt. +type Receipt struct { + ReceiptID string `json:"receipt_id"` + Hash string `json:"hash"` + Scope string `json:"scope"` + CubeID string `json:"cube_id,omitempty"` + OrgID string `json:"org_id,omitempty"` + CreatedAt string `json:"created_at"` + Merkle string `json:"merkle"` + RestampOf string `json:"restamp_of,omitempty"` +} + +// VerifyResult is the outcome of Verify. +type VerifyResult struct { + Valid bool + Error string + Receipt *Receipt +} + +// LoginResult is returned by Login (never includes the JWT). +type LoginResult struct { + Username string + Role string + ExpiresAt string +} + +// SessionResult is returned by Session /auth/validate. +type SessionResult struct { + Valid bool + Username string + Role string + ExpiresAt string +} + +// CreateUserResult is returned by Signup and CreateUser. +type CreateUserResult struct { + ID string + Username string + Role string +} + +// Client talks to the VeraeTime HTTPS API. Callers pass a resolved bearer; +// host-token minting lives on Keeper. +type Client struct { + baseURL string + httpClient *http.Client + authPath string + pollInterval time.Duration + maxPolls int +} + +// Option configures Client. +type Option func(*Client) + +// WithHTTPClient sets the underlying HTTP client. +func WithHTTPClient(c *http.Client) Option { + return func(cl *Client) { + if c != nil { + cl.httpClient = c + } + } +} + +// WithRoundTripper injects a RoundTripper (unit tests). +func WithRoundTripper(rt http.RoundTripper) Option { + return func(cl *Client) { + if rt == nil { + return + } + cl.httpClient = &http.Client{Transport: rt} + } +} + +// WithAuthPath sets the auth.properties path used by Login persistence. +func WithAuthPath(path string) Option { + return func(cl *Client) { + if path != "" { + cl.authPath = path + } + } +} + +// WithPollInterval sets status-poll sleep (default 250ms). +func WithPollInterval(d time.Duration) Option { + return func(cl *Client) { + if d > 0 { + cl.pollInterval = d + } + } +} + +// WithMaxPolls caps /api/status polls (default 40). +func WithMaxPolls(n int) Option { + return func(cl *Client) { + if n > 0 { + cl.maxPolls = n + } + } +} + +// NewClient builds a VeraeTime HTTPS client. +func NewClient(baseURL string, opts ...Option) *Client { + u := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if u == "" { + u = DefaultBaseURL + } + cl := &Client{ + baseURL: u, + httpClient: &http.Client{Timeout: 30 * time.Second}, + pollInterval: defaultPollInterval, + maxPolls: defaultMaxPolls, + } + for _, opt := range opts { + opt(cl) + } + return cl +} + +// AuthPath returns the configured auth.properties path (may be empty). +func (c *Client) AuthPath() string { return c.authPath } + +// BaseURL returns the API base URL. +func (c *Client) BaseURL() string { return c.baseURL } + +// Stamp posts a SHA-256 digest with restamp=false. +func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error) { + h, err := digest.NormalizeSHA256(dig) + if err != nil { + return nil, err + } + payload := map[string]any{ + "data": h, + "digest": h, + "hashAlg": "SHA256", + "restamp": false, + } + status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/timestamp", payload, bearer) + if err != nil { + return nil, err + } + if status == http.StatusUnauthorized { + return nil, authErr(status, body) + } + if status == http.StatusOK && (str(body, "certificate") != "" || truthy(body["existing"])) { + cid := str(body, "certificate") + if cid != "" { + return &Receipt{ + ReceiptID: cid, + Hash: h, + Scope: "global", + CreatedAt: str(body, "timestamp"), + Merkle: str(body, "blockHash"), + }, nil + } + } + if status >= 300 { + return nil, httpErr("timestamp", status, body) + } + jobID := str(body, "jobId") + if jobID == "" { + jobID = str(body, "blockHash") + } + if jobID == "" { + return nil, fmt.Errorf("veraetime: timestamp response missing jobId") + } + created := str(body, "timestamp") + merkle := str(body, "blockHash") + receiptID, created, merkle, err := c.pollStatus(ctx, jobID, bearer, created, merkle) + if err != nil { + return nil, err + } + return &Receipt{ + ReceiptID: receiptID, + Hash: h, + Scope: "global", + CreatedAt: created, + Merkle: merkle, + }, nil +} + +func (c *Client) pollStatus(ctx context.Context, jobID, bearer, created, merkle string) (string, string, string, error) { + url := c.baseURL + "/api/status/" + jobID + for i := 0; i < c.maxPolls; i++ { + status, body, err := c.doJSON(ctx, http.MethodGet, url, nil, bearer) + if err != nil { + return "", created, merkle, err + } + if status == http.StatusUnauthorized { + return "", created, merkle, authErr(status, body) + } + if status >= 300 { + break + } + state := str(body, "status") + switch state { + case "failed": + msg := str(body, "error") + if msg == "" { + msg = "global timestamp job failed" + } + return "", created, merkle, fmt.Errorf("veraetime: %s", msg) + case "completed": + rid := str(body, "result") + if meta, ok := body["metadata"].(map[string]any); ok { + if t := str(meta, "timestamp"); t != "" { + created = t + } + if m := str(meta, "blockIndex"); m != "" { + merkle = m + } else if m := str(meta, "blockHash"); m != "" { + merkle = m + } + } + if t := str(body, "completedAt"); t != "" && created == "" { + created = t + } + if rid != "" { + return rid, created, merkle, nil + } + case "pending", "running", "processing", "": + select { + case <-ctx.Done(): + return "", created, merkle, ctx.Err() + case <-time.After(c.pollInterval): + } + continue + default: + break + } + break + } + return "", created, merkle, fmt.Errorf("veraetime: global timestamp job %s not completed", jobID) +} + +// Verify posts a certificate id to /api/verify. +func (c *Client) Verify(ctx context.Context, receiptID, bearer string) (VerifyResult, error) { + if strings.TrimSpace(receiptID) == "" { + return VerifyResult{Valid: false, Error: "receipt_id required"}, nil + } + status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/verify", + map[string]any{"certificate": receiptID}, bearer) + if err != nil { + return VerifyResult{}, err + } + if status == http.StatusUnauthorized { + return VerifyResult{}, authErr(status, body) + } + if status >= 300 { + return VerifyResult{Valid: false, Error: fmt.Sprintf("verify HTTP %d", status)}, nil + } + if !truthy(body["valid"]) { + return VerifyResult{Valid: false, Error: "not valid"}, nil + } + merkle := str(body, "blockHash") + if merkle == "" { + merkle = str(body, "blockIndex") + } + return VerifyResult{ + Valid: true, + Receipt: &Receipt{ + ReceiptID: receiptID, + Hash: str(body, "hash"), + Scope: "global", + CreatedAt: str(body, "timestamp"), + Merkle: merkle, + }, + }, nil +} + +// HashReport GETs /api/hash/{digest} and optionally enriches via Verify. +func (c *Client) HashReport(ctx context.Context, dig, bearer string) ([]Receipt, error) { + h, err := digest.NormalizeSHA256(dig) + if err != nil { + return nil, err + } + status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/hash/"+h, nil, bearer) + if err != nil { + return nil, err + } + if status == http.StatusUnauthorized { + return nil, authErr(status, body) + } + if status == http.StatusNotFound { + return nil, nil + } + if status >= 300 { + return nil, httpErr("hash report", status, body) + } + rawRefs, _ := body["receipts"].([]any) + out := make([]Receipt, 0, len(rawRefs)) + for _, refAny := range rawRefs { + ref, ok := refAny.(map[string]any) + if !ok { + continue + } + cid := str(ref, "certificate") + if cid == "" { + cid = str(ref, "receipt_id") + } + if cid == "" { + continue + } + created := str(ref, "completedAt") + if created == "" { + created = str(ref, "created_at") + } + restamp := str(ref, "restampOf") + if restamp == "" { + restamp = str(ref, "restamp_of") + } + hashVal := str(ref, "digest") + if hashVal == "" { + hashVal = str(ref, "hash") + } + if hashVal == "" { + hashVal = h + } + merkle := "" + if vr, vErr := c.Verify(ctx, cid, bearer); vErr == nil && vr.Valid && vr.Receipt != nil { + if vr.Receipt.CreatedAt != "" { + created = vr.Receipt.CreatedAt + } + if vr.Receipt.Merkle != "" { + merkle = vr.Receipt.Merkle + } + if vr.Receipt.Hash != "" { + hashVal = vr.Receipt.Hash + } + } + out = append(out, Receipt{ + ReceiptID: cid, + Hash: hashVal, + Scope: "global", + CreatedAt: created, + Merkle: merkle, + RestampOf: restamp, + }) + } + return out, nil +} + +// Signup creates a role=user account (no Authorization header). +func (c *Client) Signup(ctx context.Context, username, password string) (CreateUserResult, error) { + if username == "" || password == "" { + return CreateUserResult{}, fmt.Errorf("username and password are required") + } + status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/auth/signup", + map[string]any{"username": username, "password": password}, "") + if err != nil { + return CreateUserResult{}, err + } + if status >= 300 { + return CreateUserResult{}, httpErr("signup", status, body) + } + role := str(body, "role") + if role == "" { + role = "user" + } + uname := str(body, "username") + if uname == "" { + uname = username + } + return CreateUserResult{ID: str(body, "id"), Username: uname, Role: role}, nil +} + +// Login authenticates and persists JWT + credentials to auth.properties. +func (c *Client) Login(ctx context.Context, username, password string) (LoginResult, error) { + res, token, err := c.authenticate(ctx, username, password) + if err != nil { + return LoginResult{}, err + } + path := c.authPath + if path == "" { + path = DefaultAuthPath() + c.authPath = path + } + if err := WriteProperties(path, map[string]string{ + "access_token": token, + "id_token": token, + "auth_token": token, + "expires_at": res.ExpiresAt, + "username": username, + "password": password, + }); err != nil { + return LoginResult{}, err + } + return res, nil +} + +// Authenticate checks username/password and returns the JWT without persisting. +func (c *Client) Authenticate(ctx context.Context, username, password string) (LoginResult, string, error) { + return c.authenticate(ctx, username, password) +} + +func (c *Client) authenticate(ctx context.Context, username, password string) (LoginResult, string, error) { + if username == "" || password == "" { + return LoginResult{}, "", fmt.Errorf("username and password are required") + } + status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/auth/login", + map[string]any{"username": username, "password": password}, "") + if err != nil { + return LoginResult{}, "", err + } + if status >= 300 { + return LoginResult{}, "", httpErr("login", status, body) + } + token := str(body, "token") + if token == "" { + return LoginResult{}, "", fmt.Errorf("login response missing token") + } + user, _ := body["user"].(map[string]any) + uname := str(user, "username") + if uname == "" { + uname = username + } + return LoginResult{ + Username: uname, + Role: str(user, "role"), + ExpiresAt: str(body, "expiresAt"), + }, token, nil +} + +// Session validates a bearer via GET /auth/validate. +func (c *Client) Session(ctx context.Context, bearer string) (SessionResult, error) { + if strings.TrimSpace(bearer) == "" { + return SessionResult{}, nil + } + status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/auth/validate", nil, bearer) + if err != nil { + return SessionResult{}, err + } + if status >= 300 || !truthy(body["valid"]) { + return SessionResult{}, nil + } + return SessionResult{ + Valid: true, + Username: str(body, "username"), + Role: str(body, "role"), + ExpiresAt: str(body, "expiresAt"), + }, nil +} + +// CreateUser posts /auth/users (admin bearer required by upstream). +func (c *Client) CreateUser(ctx context.Context, username, password, role, bearer string) (CreateUserResult, error) { + if username == "" || password == "" || role == "" { + return CreateUserResult{}, fmt.Errorf("username, password, and role are required") + } + sess, err := c.Session(ctx, bearer) + if err != nil { + return CreateUserResult{}, err + } + if !sess.Valid || sess.Role != "admin" { + return CreateUserResult{}, fmt.Errorf("admin login required") + } + status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/auth/users", + map[string]any{"username": username, "password": password, "role": role}, bearer) + if err != nil { + return CreateUserResult{}, err + } + if status == http.StatusUnauthorized { + return CreateUserResult{}, authErr(status, body) + } + if status >= 300 { + return CreateUserResult{}, httpErr("create user", status, body) + } + uname := str(body, "username") + if uname == "" { + uname = username + } + r := str(body, "role") + if r == "" { + r = role + } + return CreateUserResult{ID: str(body, "id"), Username: uname, Role: r}, nil +} + +func (c *Client) doJSON(ctx context.Context, method, url string, payload map[string]any, bearer string) (int, map[string]any, error) { + var bodyReader io.Reader + if method != http.MethodGet && payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return 0, nil, err + } + bodyReader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return 0, nil, err + } + if method != http.MethodGet { + req.Header.Set("Content-Type", "application/json") + } + if b := strings.TrimSpace(bearer); b != "" { + req.Header.Set("Authorization", "Bearer "+b) + } + res, err := c.httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer res.Body.Close() + raw, err := io.ReadAll(res.Body) + if err != nil { + return res.StatusCode, nil, err + } + var parsed map[string]any + if len(raw) > 0 { + if err := json.Unmarshal(raw, &parsed); err != nil { + parsed = map[string]any{"error": string(raw)} + } + } + if parsed == nil { + parsed = map[string]any{} + } + return res.StatusCode, parsed, nil +} + +func str(m map[string]any, key string) string { + if m == nil { + return "" + } + v, ok := m[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case float64: + return fmt.Sprintf("%.0f", t) + case json.Number: + return t.String() + default: + return fmt.Sprint(t) + } +} + +func truthy(v any) bool { + switch t := v.(type) { + case bool: + return t + case string: + return t == "true" || t == "1" + default: + return false + } +} + +func authErr(status int, body map[string]any) error { + msg := str(body, "error") + return &AuthError{Status: status, Message: msg} +} + +func httpErr(op string, status int, body map[string]any) error { + detail := str(body, "error") + if detail == "" { + return fmt.Errorf("veraetime: %s HTTP %d", op, status) + } + return fmt.Errorf("veraetime: %s HTTP %d: %s", op, status, detail) +} diff --git a/internal/veraetime/client_test.go b/internal/veraetime/client_test.go new file mode 100644 index 0000000..f2da22a --- /dev/null +++ b/internal/veraetime/client_test.go @@ -0,0 +1,407 @@ +package veraetime_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" +) + +const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func jsonResp(status int, body any) *http.Response { + raw, _ := json.Marshal(body) + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(raw)), + } +} + +func readJSON(t *testing.T, r *http.Request) map[string]any { + t.Helper() + defer r.Body.Close() + var m map[string]any + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + t.Fatalf("decode body: %v", err) + } + return m +} + +func TestStamp_PostsDigestRestampFalse(t *testing.T) { + var gotBody map[string]any + var gotAuth string + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/timestamp") { + t.Fatalf("unexpected %s %s", r.Method, r.URL) + } + gotAuth = r.Header.Get("Authorization") + gotBody = readJSON(t, r) + return jsonResp(200, map[string]any{ + "jobId": "job-1", + "existing": true, + "certificate": "QmCert1", + "timestamp": "2026-08-15T00:00:00Z", + "blockHash": "0xdead", + }), nil + }) + c := veraetime.NewClient("https://api.veraetime.net", veraetime.WithRoundTripper(rt)) + rec, err := c.Stamp(context.Background(), sha256Empty, "secret") + if err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer secret" { + t.Fatalf("auth=%q", gotAuth) + } + if gotBody["data"] != sha256Empty || gotBody["digest"] != sha256Empty { + t.Fatalf("body=%v", gotBody) + } + if gotBody["hashAlg"] != "SHA256" { + t.Fatalf("hashAlg=%v", gotBody["hashAlg"]) + } + if gotBody["restamp"] != false { + t.Fatalf("restamp=%v want false", gotBody["restamp"]) + } + if rec.ReceiptID != "QmCert1" || rec.Scope != "global" || rec.Hash != sha256Empty { + t.Fatalf("rec=%+v", rec) + } +} + +func TestStamp_ExistingCertificateNoPoll(t *testing.T) { + calls := 0 + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + if strings.Contains(r.URL.Path, "/api/status/") { + t.Fatal("must not poll status when certificate present") + } + return jsonResp(200, map[string]any{ + "jobId": "28df9952-094b-45a9-b6e5-e3ff81389bdd", + "existing": true, + "certificate": "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA", + }), nil + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt)) + rec, err := c.Stamp(context.Background(), sha256Empty, "tok") + if err != nil { + t.Fatal(err) + } + if rec.ReceiptID != "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA" { + t.Fatalf("receipt=%q", rec.ReceiptID) + } + if calls != 1 { + t.Fatalf("calls=%d", calls) + } +} + +func TestStamp_PollsUntilCompleted(t *testing.T) { + statusCalls := 0 + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/api/timestamp"): + return jsonResp(202, map[string]any{"jobId": "job-p"}), nil + case strings.HasSuffix(r.URL.Path, "/api/status/job-p"): + statusCalls++ + if statusCalls < 3 { + return jsonResp(200, map[string]any{"id": "job-p", "status": "processing"}), nil + } + return jsonResp(200, map[string]any{ + "id": "job-p", + "status": "completed", + "result": "QmDone", + "metadata": map[string]any{ + "timestamp": "2026-09-23T00:00:00Z", + "blockHash": "0xabc", + }, + }), nil + default: + return jsonResp(404, map[string]any{"error": "nope"}), nil + } + }) + c := veraetime.NewClient("https://api.example", + veraetime.WithRoundTripper(rt), + veraetime.WithPollInterval(time.Millisecond), + ) + rec, err := c.Stamp(context.Background(), sha256Empty, "tok") + if err != nil { + t.Fatal(err) + } + if rec.ReceiptID != "QmDone" { + t.Fatalf("receipt=%q", rec.ReceiptID) + } + if statusCalls != 3 { + t.Fatalf("statusCalls=%d", statusCalls) + } +} + +func TestStamp_401AuthError(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return jsonResp(401, map[string]any{"error": "Unauthorized"}), nil + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt)) + _, err := c.Stamp(context.Background(), sha256Empty, "bad") + if err == nil { + t.Fatal("expected error") + } + var ae *veraetime.AuthError + if !asAuthError(err, &ae) { + t.Fatalf("want AuthError, got %T %v", err, err) + } + if ae.Status != 401 { + t.Fatalf("status=%d", ae.Status) + } +} + +func asAuthError(err error, target **veraetime.AuthError) bool { + if ae, ok := err.(*veraetime.AuthError); ok { + *target = ae + return true + } + return false +} + +func TestVerify_PostsCertificate(t *testing.T) { + var gotBody map[string]any + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/verify") { + t.Fatalf("unexpected %s %s", r.Method, r.URL) + } + gotBody = readJSON(t, r) + return jsonResp(200, map[string]any{ + "valid": true, + "hash": sha256Empty, + "timestamp": "2026-08-15T00:00:00Z", + "blockHash": "0xdead", + }), nil + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt)) + vr, err := c.Verify(context.Background(), "QmCert1", "tok") + if err != nil { + t.Fatal(err) + } + if !vr.Valid || vr.Receipt == nil || vr.Receipt.Hash != sha256Empty { + t.Fatalf("%+v", vr) + } + if gotBody["certificate"] != "QmCert1" { + t.Fatalf("body=%v", gotBody) + } +} + +func TestHashReport_UsesAPIHash(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case strings.Contains(r.URL.Path, "/api/hash/"): + return jsonResp(200, map[string]any{ + "digest": sha256Empty, + "receipts": []map[string]any{{ + "certificate": "job-1", + "digest": sha256Empty, + "completedAt": "2026-08-15T00:00:00Z", + }}, + }), nil + case strings.HasSuffix(r.URL.Path, "/api/verify"): + return jsonResp(200, map[string]any{ + "valid": true, + "hash": sha256Empty, + "timestamp": "2026-08-15T00:00:00Z", + "blockIndex": 113072, + }), nil + default: + return jsonResp(404, map[string]any{"error": "nope"}), nil + } + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt)) + recs, err := c.HashReport(context.Background(), sha256Empty, "tok") + if err != nil { + t.Fatal(err) + } + if len(recs) != 1 || recs[0].ReceiptID != "job-1" || recs[0].Merkle != "113072" { + t.Fatalf("%+v", recs) + } +} + +func TestLogin_StoresAuthProperties(t *testing.T) { + dir := t.TempDir() + path := dir + "/auth.properties" + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/auth/login") { + t.Fatalf("url=%s", r.URL) + } + body := readJSON(t, r) + if body["username"] != "admin" || body["password"] != "secret-pass" { + t.Fatalf("body=%v", body) + } + return jsonResp(200, map[string]any{ + "token": "fresh-token", + "expiresAt": "2099-01-01T00:00:00Z", + "user": map[string]any{"username": "admin", "role": "admin"}, + }), nil + }) + c := veraetime.NewClient("https://api.example", + veraetime.WithRoundTripper(rt), + veraetime.WithAuthPath(path), + ) + res, err := c.Login(context.Background(), "admin", "secret-pass") + if err != nil { + t.Fatal(err) + } + if res.Username != "admin" || res.Role != "admin" || res.ExpiresAt != "2099-01-01T00:00:00Z" { + t.Fatalf("%+v", res) + } + props, err := veraetime.ReadProperties(path) + if err != nil { + t.Fatal(err) + } + if props["access_token"] != "fresh-token" || props["password"] != "secret-pass" { + t.Fatalf("%v", props) + } +} + +func TestSignup_DoesNotSendBearer(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Authorization") != "" { + t.Fatal("signup must not send Authorization") + } + return jsonResp(201, map[string]any{"id": "u3", "username": "newcomer", "role": "user"}), nil + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt)) + res, err := c.Signup(context.Background(), "newcomer", "secret-pass") + if err != nil { + t.Fatal(err) + } + if res.ID != "u3" || res.Username != "newcomer" { + t.Fatalf("%+v", res) + } +} + +func TestSession_AndCreateUser(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(r.URL.Path, "/auth/validate"): + if r.Header.Get("Authorization") != "Bearer fresh-token" { + return jsonResp(401, map[string]any{"error": "Unauthorized"}), nil + } + return jsonResp(200, map[string]any{ + "valid": true, "username": "admin", "role": "admin", "expiresAt": "2099-01-01T00:00:00Z", + }), nil + case strings.HasSuffix(r.URL.Path, "/auth/users"): + body := readJSON(t, r) + return jsonResp(201, map[string]any{ + "id": "u2", "username": body["username"], "role": body["role"], + }), nil + default: + return jsonResp(404, map[string]any{"error": "nope"}), nil + } + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt)) + sess, err := c.Session(context.Background(), "fresh-token") + if err != nil { + t.Fatal(err) + } + if !sess.Valid || sess.Role != "admin" { + t.Fatalf("%+v", sess) + } + created, err := c.CreateUser(context.Background(), "newuser", "secret-pass", "user", "fresh-token") + if err != nil { + t.Fatal(err) + } + if created.ID != "u2" || created.Username != "newuser" { + t.Fatalf("%+v", created) + } +} + +func TestKeeper_RefreshesNearExpiry(t *testing.T) { + dir := t.TempDir() + path := dir + "/auth.properties" + if err := veraetime.WriteProperties(path, map[string]string{ + "access_token": "old-token", + "expires_at": time.Now().UTC().Add(30 * time.Second).Format(time.RFC3339), + "username": "admin", + "password": "secret-pass", + }); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var urls []string + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + mu.Lock() + urls = append(urls, r.URL.Path) + mu.Unlock() + if strings.HasSuffix(r.URL.Path, "/auth/login") { + return jsonResp(200, map[string]any{ + "token": "fresh-token", + "expiresAt": "2099-01-01T00:00:00Z", + "user": map[string]any{"username": "admin", "role": "admin"}, + }), nil + } + return jsonResp(404, map[string]any{"error": "nope"}), nil + }) + c := veraetime.NewClient("https://api.example", + veraetime.WithRoundTripper(rt), + veraetime.WithAuthPath(path), + ) + k := veraetime.NewKeeper(c, path) + tok, err := k.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if tok != "fresh-token" { + t.Fatalf("tok=%q", tok) + } + mu.Lock() + defer mu.Unlock() + if len(urls) == 0 || !strings.HasSuffix(urls[0], "/auth/login") { + t.Fatalf("urls=%v", urls) + } +} + +func TestKeeper_UsesGlobalTokenEnv(t *testing.T) { + t.Setenv("VERAE_GLOBAL_TOKEN", "env-bearer") + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Fatal("no HTTP expected when VERAE_GLOBAL_TOKEN set") + return nil, nil + }))) + k := veraetime.NewKeeper(c, t.TempDir()+"/missing.properties", veraetime.WithGlobalTokenEnv(true)) + tok, err := k.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if tok != "env-bearer" { + t.Fatalf("tok=%q", tok) + } +} + +func TestKeeper_UsesFreshStoredToken(t *testing.T) { + dir := t.TempDir() + path := dir + "/auth.properties" + if err := veraetime.WriteProperties(path, map[string]string{ + "access_token": "still-good", + "expires_at": "2099-01-01T00:00:00Z", + "username": "admin", + "password": "secret-pass", + }); err != nil { + t.Fatal(err) + } + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Fatal("no login expected") + return nil, nil + }))) + k := veraetime.NewKeeper(c, path) + tok, err := k.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if tok != "still-good" { + t.Fatalf("tok=%q", tok) + } +} diff --git a/internal/veraetime/keeper.go b/internal/veraetime/keeper.go new file mode 100644 index 0000000..9a928e0 --- /dev/null +++ b/internal/veraetime/keeper.go @@ -0,0 +1,144 @@ +package veraetime + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" +) + +// Keeper mints and refreshes a host bearer for VeraeTime when callers omit one. +type Keeper struct { + client *Client + authPath string + globalToken string + useEnv bool + + mu sync.Mutex + token string + expiresAt time.Time + username string + password string + loaded bool +} + +// KeeperOption configures Keeper. +type KeeperOption func(*Keeper) + +// WithGlobalToken sets a static host bearer (overrides file until empty). +func WithGlobalToken(tok string) KeeperOption { + return func(k *Keeper) { + k.globalToken = strings.TrimSpace(tok) + } +} + +// WithGlobalTokenEnv loads VERAE_GLOBAL_TOKEN when Token is called. +func WithGlobalTokenEnv(enabled bool) KeeperOption { + return func(k *Keeper) { + k.useEnv = enabled + } +} + +// NewKeeper builds a host-token keeper bound to client and auth.properties path. +func NewKeeper(client *Client, authPath string, opts ...KeeperOption) *Keeper { + k := &Keeper{ + client: client, + authPath: authPath, + useEnv: true, + } + for _, opt := range opts { + opt(k) + } + return k +} + +// Token returns a usable host bearer: VERAE_GLOBAL_TOKEN, stored access_token, +// or a fresh login persisted to auth.properties. Never logs the token. +func (k *Keeper) Token(ctx context.Context) (string, error) { + if k == nil { + return "", fmt.Errorf("veraetime: nil keeper") + } + k.mu.Lock() + defer k.mu.Unlock() + + if k.useEnv { + if env := strings.TrimSpace(os.Getenv("VERAE_GLOBAL_TOKEN")); env != "" { + return env, nil + } + } + if tok := strings.TrimSpace(k.globalToken); tok != "" { + return tok, nil + } + + if err := k.loadLocked(); err != nil { + return "", err + } + + if k.token != "" && !k.needsRefreshLocked() { + return k.token, nil + } + if k.username == "" || k.password == "" { + if k.token != "" { + return k.token, nil + } + return "", fmt.Errorf("veraetime: no host token (set VERAE_GLOBAL_TOKEN or auth.properties)") + } + if k.client == nil { + return "", fmt.Errorf("veraetime: nil client") + } + if k.authPath != "" && k.client.authPath == "" { + k.client.authPath = k.authPath + } + res, err := k.client.Login(ctx, k.username, k.password) + if err != nil { + return "", err + } + props, err := ReadProperties(k.authPath) + if err != nil { + return "", err + } + tok := storedAccessToken(props) + if tok == "" { + return "", fmt.Errorf("veraetime: login did not persist access_token") + } + k.token = tok + if exp, ok := ParseExpiry(res.ExpiresAt); ok { + k.expiresAt = exp + } + return k.token, nil +} + +func (k *Keeper) loadLocked() error { + if k.loaded { + return nil + } + k.loaded = true + if k.authPath == "" { + return nil + } + props, err := ReadProperties(k.authPath) + if err != nil { + return err + } + if k.token == "" { + k.token = storedAccessToken(props) + } + k.username = props["username"] + k.password = props["password"] + if exp, ok := ParseExpiry(props["expires_at"]); ok { + k.expiresAt = exp + } + return nil +} + +func (k *Keeper) needsRefreshLocked() bool { + if k.username == "" || k.password == "" { + return false + } + if k.token == "" || k.expiresAt.IsZero() { + return true + } + return !time.Now().UTC().Before(k.expiresAt.Add(-TokenRefreshSkew)) +}