package veraetime import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/metadatahist" "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 } // StampMeta is optional history for one stamp. The zero value keeps the // digest-only body: restamp false and no metadata field. type StampMeta struct { Metadata map[string]any Restamp bool Message string At string } // Stamp posts a SHA-256 digest with restamp=false and no metadata. func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error) { return c.StampWithMeta(ctx, dig, bearer, StampMeta{}) } // StampWithMeta posts a digest. Metadata or Restamp sends a verae-meta-git/1 // document. A second submit of an existing digest without Restamp, when // metadata was sent, returns an error so the first history is left unchanged. func (c *Client) StampWithMeta(ctx context.Context, dig, bearer string, meta StampMeta) (*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, } if err := c.attachHistory(ctx, h, bearer, meta, payload); err != nil { return nil, err } 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"])) { if payload["metadata"] != nil && !truthy(payload["restamp"]) { return nil, fmt.Errorf("veraetime: digest already stamped; set restamp to append metadata") } 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 = str(body, "username") } if uname == "" { uname = username } expires := str(body, "expiresAt") if expires == "" { expires = str(body, "expires_at") } role := str(user, "role") if role == "" { role = str(body, "role") } return LoginResult{ Username: uname, Role: role, ExpiresAt: expires, }, 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) == "" { 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 status == http.StatusPaymentRequired && detail != "" { return fmt.Errorf("%s", strings.TrimPrefix(detail, "payment required: ")) // DEC-658: the clear 402 message as is } if detail == "" { return fmt.Errorf("veraetime: %s HTTP %d", op, status) } return fmt.Errorf("veraetime: %s HTTP %d: %s", op, status, detail) } // BatchItem is one item of a VeraeTime batch answer (DEC-659). type BatchItem struct { Index int `json:"index"` Digest string `json:"digest"` JobID string `json:"jobId"` Status string `json:"status"` Existing bool `json:"existing"` Certificate string `json:"certificate"` Credits int `json:"credits"` Error string `json:"error"` } // BatchResult is VeraeTime's per-item answer to POST /api/batch/timestamp with a Verae API key. type BatchResult struct { Count int `json:"count"` Created int `json:"created"` Existing int `json:"existing"` Failed int `json:"failed"` TotalCredits int `json:"totalCredits"` BalanceCredits *int `json:"balanceCredits"` Results []BatchItem `json:"results"` } func (c *Client) attachHistory(ctx context.Context, dig, bearer string, meta StampMeta, payload map[string]any) error { if len(meta.Metadata) == 0 && !meta.Restamp && !metadatahist.IsHistory(meta.Metadata) { return nil } var prior any if meta.Restamp && !metadatahist.IsHistory(meta.Metadata) { loaded, err := c.priorMetadata(ctx, dig, bearer) if err != nil { return err } prior = loaded } doc := metadatahist.Append(prior, meta.Metadata, meta.Restamp, meta.At, "", meta.Message) if metadatahist.IsHistory(meta.Metadata) { doc = meta.Metadata } if doc == nil { return nil } payload["metadata"] = doc if meta.Restamp { payload["restamp"] = true } if commits, ok := doc["commits"].([]any); ok && len(commits) > 1 { payload["restamp"] = true } return nil } func (c *Client) priorMetadata(ctx context.Context, dig, bearer string) (map[string]any, error) { status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/hash/"+dig, nil, bearer) if err != nil { return nil, err } if status == http.StatusNotFound { return nil, nil } if status == http.StatusUnauthorized { return nil, authErr(status, body) } if status >= 300 { return nil, httpErr("hash report", status, body) } refs, _ := body["receipts"].([]any) jobID := "" for _, refAny := range refs { ref, ok := refAny.(map[string]any) if !ok { continue } if id := str(ref, "jobId"); id != "" { jobID = id } } if jobID == "" { return nil, nil } st, sb, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/status/"+jobID, nil, bearer) if err != nil { return nil, err } if st == http.StatusNotFound { return nil, nil } if st == http.StatusUnauthorized { return nil, authErr(st, sb) } if st >= 300 { return nil, httpErr("status", st, sb) } meta, _ := sb["metadata"].(map[string]any) return meta, nil } // StampBatch posts SHA-256 digests (restamp=false) in one batch; each new digest is charged to the key's customer. func (c *Client) StampBatch(ctx context.Context, digests []string, bearer string) (*BatchResult, error) { return c.StampBatchWithMeta(ctx, digests, bearer, nil) } // StampBatchWithMeta is StampBatch with one optional history per digest. // A nil metas slice keeps every item digest-only. A non-nil slice must match // len(digests). func (c *Client) StampBatchWithMeta(ctx context.Context, digests []string, bearer string, metas []StampMeta) (*BatchResult, error) { if metas != nil && len(metas) != len(digests) { return nil, fmt.Errorf("veraetime: metadata entries (%d) must match digests (%d)", len(metas), len(digests)) } items := make([]map[string]any, 0, len(digests)) for i, d := range digests { h, err := digest.NormalizeSHA256(d) if err != nil { return nil, fmt.Errorf("sha256s[%d]: %w", i, err) } item := map[string]any{"data": h, "digest": h, "hashAlg": "SHA256", "restamp": false} if metas != nil { if err := c.attachHistory(ctx, h, bearer, metas[i], item); err != nil { return nil, err } } items = append(items, item) } status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/batch/timestamp", map[string]any{"items": items}, bearer) if err != nil { return nil, err } if status == http.StatusUnauthorized { return nil, authErr(status, body) } if status >= 300 { return nil, httpErr("batch timestamp", status, body) } raw, _ := json.Marshal(body) var out BatchResult if err := json.Unmarshal(raw, &out); err != nil || len(out.Results) != len(digests) { return nil, fmt.Errorf("veraetime: batch answer has %d results for %d digests", len(out.Results), len(digests)) } return &out, nil }