verae-mcp-timestamping-serv.../internal/veraetime/client.go
George Lambert 3ec1f0516b
Some checks failed
test-and-deploy / test (push) Failing after 12m55s
test-and-deploy / deploy-138 (push) Has been cancelled
feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Add receipt_format to stamp and verify. Omitted → text; receipt|PDF-Receipt → PDF;
JSON-Receipt → JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt.

Stamp always returns receipts[] and adds artifacts[] in the chosen format.
Stdio writes ~/.verae/receipts (0600); public MCP returns content_text/content_base64 only.
Verify accepts inline receipt or receipt_path (stdio), parses id, and re-emits an artifact.

Vendors pdfcert (+ slim statuslang, gofpdf) for CERT-01 PDF. Also lands welcome tool
and email-username helpers already wired into register/public paths.
2026-10-20 16:02:20 -04:00

794 lines
21 KiB
Go

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/<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) == "" {
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
}