verae-mcp-timestamping-serv.../internal/veraetime/client.go
George Lambert 348ccd0bf2 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.
2026-09-29 11:57:40 -04:00

592 lines
15 KiB
Go

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