Optional metadata or restamp builds the same commit history as node-red. A call that omits both stays digest-only with restamp false. Local and shared stamps reject metadata.
783 lines
21 KiB
Go
783 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 = username
|
|
}
|
|
return LoginResult{
|
|
Username: uname,
|
|
Role: str(user, "role"),
|
|
ExpiresAt: str(body, "expiresAt"),
|
|
}, 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
|
|
}
|