POST /v1/identity/verify: an AERE Identity presentation judged by the published command line -> VALID / PARTIAL / INVALID with every row, the claims shown and the subject when no row failed, and the command that reproduces the verdict. POST /v1/compliance/check: your policy judged on a presentation -> COMPLIANT / NOT_COMPLIANT with the reasons and, with record, the decision as an AIP-23 envelope without personal data. In Go the presentation, keys, lists and policy travel as json.RawMessage: a map or float64 would change numbers above 2^53 and the canonical text the signatures cover. The .NET negative control now builds clean for each planted defect (an incremental build could run the tests against the previous planted library). A negative control for the JavaScript and Python routes is added. Tests: JavaScript 13, Python 14, Go ok, Java 10/10 (and the Java 8 API gate); negative controls Go 9/9, Java 10/10, .NET 12/12, JavaScript and Python 4/4.
407 lines
17 KiB
Go
407 lines
17 KiB
Go
// Package aerecloud is the official Go client for the Aere Cloud API (Aere Network, chain 2800).
|
|
//
|
|
// Same routes and semantics as @aere/cloud (JS), aere-cloud (Python) and aere-cloud (Java) 1.5.2. Zero dependencies
|
|
// beyond the Go standard library. JSON routes return the server's JSON as json.RawMessage, so the integrator decodes it
|
|
// into whatever types they already use; the two routes that return FILES (the audit export and the check of a kept
|
|
// export) return typed results with the digest computed locally from the bytes received.
|
|
//
|
|
// Non-2xx answers are returned as *Error with the HTTP status, the server's body and the path.
|
|
package aerecloud
|
|
|
|
// Nota interna: comentariile de implementare sunt in romana fara diacritice, ca in celelalte SDK-uri ale casei;
|
|
// documentatia publica a API-ului (comentariile exportate) e in engleza.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route).
|
|
const Version = "1.7.0"
|
|
|
|
// DefaultBaseURL is the production gateway.
|
|
const DefaultBaseURL = "https://cloud.aere.network/v1"
|
|
|
|
// Error is a non-2xx answer from Aere Cloud (Status 0 means the request never got an answer).
|
|
type Error struct {
|
|
Status int
|
|
Body []byte
|
|
Path string
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
b := string(e.Body)
|
|
if b == "" {
|
|
b = "request failed"
|
|
}
|
|
return fmt.Sprintf("Aere Cloud %d on %s: %s", e.Status, e.Path, b)
|
|
}
|
|
|
|
// Client talks to one Aere Cloud gateway with one API key (ak2800.<address>.<secret>).
|
|
type Client struct {
|
|
apiKey string
|
|
baseURL string
|
|
http *http.Client
|
|
}
|
|
|
|
// New returns a client for the production gateway. The API key is required.
|
|
func New(apiKey string) (*Client, error) { return NewWithBase(apiKey, DefaultBaseURL, nil) }
|
|
|
|
// NewWithBase returns a client for another base URL (a test server) and an optional *http.Client.
|
|
func NewWithBase(apiKey, baseURL string, hc *http.Client) (*Client, error) {
|
|
if apiKey == "" {
|
|
return nil, errors.New("aerecloud: apiKey is required (ak2800.<address>.<secret>)")
|
|
}
|
|
if hc == nil {
|
|
// fara redirectari urmate: un 3xx e un raspuns, nu un drum spre alta gazda care ar primi cheia
|
|
hc = &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
}
|
|
return &Client{apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), http: hc}, nil
|
|
}
|
|
|
|
type raspuns struct {
|
|
status int
|
|
body []byte
|
|
headers http.Header
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body any, auth bool) (*raspuns, error) {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rd = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("user-agent", "aere-cloud-go/"+Version)
|
|
if body != nil {
|
|
req.Header.Set("content-type", "application/json")
|
|
}
|
|
if auth {
|
|
req.Header.Set("x-api-key", c.apiKey)
|
|
}
|
|
r, err := c.http.Do(req)
|
|
if err != nil {
|
|
// mesajul de retea nu poarta cheia (e in antet, nu in URL)
|
|
return nil, &Error{Status: 0, Body: []byte(err.Error()), Path: path}
|
|
}
|
|
defer r.Body.Close()
|
|
b, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, &Error{Status: 0, Body: []byte(err.Error()), Path: path}
|
|
}
|
|
return &raspuns{status: r.StatusCode, body: b, headers: r.Header}, nil
|
|
}
|
|
|
|
func (c *Client) jsonReq(ctx context.Context, method, path string, body any, auth bool) (json.RawMessage, error) {
|
|
r, err := c.do(ctx, method, path, body, auth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.status < 200 || r.status >= 300 {
|
|
return nil, &Error{Status: r.status, Body: r.body, Path: path}
|
|
}
|
|
return json.RawMessage(r.body), nil
|
|
}
|
|
|
|
func esc(s string) string { return url.PathEscape(s) }
|
|
|
|
// ---- health, account, JSON-RPC ----
|
|
|
|
func (c *Client) Health(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/health", nil, false)
|
|
}
|
|
func (c *Client) Account(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/account", nil, true)
|
|
}
|
|
|
|
// RPC sends one JSON-RPC call and returns the whole JSON-RPC envelope.
|
|
func (c *Client) RPC(ctx context.Context, method string, params []any) (json.RawMessage, error) {
|
|
if params == nil {
|
|
params = []any{}
|
|
}
|
|
return c.jsonReq(ctx, "POST", "/rpc", map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, true)
|
|
}
|
|
|
|
// BlockNumber is eth_blockNumber as an integer.
|
|
func (c *Client) BlockNumber(ctx context.Context) (uint64, error) {
|
|
b, err := c.RPC(ctx, "eth_blockNumber", nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var env struct {
|
|
Result string `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(b, &env); err != nil || !strings.HasPrefix(env.Result, "0x") {
|
|
return 0, &Error{Status: 200, Body: b, Path: "/rpc"}
|
|
}
|
|
return strconv.ParseUint(env.Result[2:], 16, 64)
|
|
}
|
|
|
|
// ---- post-quantum verification, chain data, notarization, proofs ----
|
|
|
|
// PQVerify: payload like {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."}.
|
|
func (c *Client) PQVerify(ctx context.Context, payload any) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/pq/verify", payload, true)
|
|
}
|
|
|
|
// Verify is the Trust API (1.6.0): one AERE Proof Protocol (AIP-23) envelope of any kind -> VALID / INVALID / PARTIAL with the levels checked and the command that reproduces the verdict; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify.
|
|
// chain: 2800 or 28001; 0 = the chain the envelope declares.
|
|
// envelope: the envelope's JSON bytes as you received them. The verdict hashes the statement in its own key order, so the bytes are
|
|
// sent as they are; a Go map would sort the keys and turn a valid envelope into an INVALID one (measured 2026-09-29, before 1.6.0 shipped).
|
|
func (c *Client) Verify(ctx context.Context, envelope json.RawMessage, chain uint64) (json.RawMessage, error) {
|
|
p := "/verify"
|
|
if chain > 0 {
|
|
p += "?chain=" + strconv.FormatUint(chain, 10)
|
|
}
|
|
return c.jsonReq(ctx, "POST", p, envelope, true)
|
|
}
|
|
// IdentityVerifyRequest is the body of POST /v1/identity/verify (1.7.0). The presentation, keys, status lists and revocations are kept as
|
|
// their JSON bytes: a Go map or float64 would change numbers above 2^53 and the canonical text the signatures cover.
|
|
type IdentityVerifyRequest struct {
|
|
Presentation json.RawMessage `json:"presentation"`
|
|
Audience string `json:"audience"`
|
|
Nonce string `json:"nonce"`
|
|
TrustedIssuers []json.RawMessage `json:"trustedIssuers,omitempty"`
|
|
StatusLists []json.RawMessage `json:"statusLists,omitempty"`
|
|
Revocations []json.RawMessage `json:"revocations,omitempty"`
|
|
MaxAgeSeconds int `json:"maxAgeSeconds,omitempty"`
|
|
}
|
|
|
|
// ComplianceCheckRequest is the body of POST /v1/compliance/check (1.7.0).
|
|
type ComplianceCheckRequest struct {
|
|
Presentation json.RawMessage `json:"presentation"`
|
|
Policy json.RawMessage `json:"policy"`
|
|
Audience string `json:"audience"`
|
|
Nonce string `json:"nonce"`
|
|
StatusLists []json.RawMessage `json:"statusLists,omitempty"`
|
|
Revocations []json.RawMessage `json:"revocations,omitempty"`
|
|
Record bool `json:"record,omitempty"`
|
|
}
|
|
|
|
// IdentityVerify (1.7.0): an AERE Identity presentation judged by the published command line -> VALID / PARTIAL / INVALID with every row,
|
|
// the claims shown and the subject when no row failed, and the command (with the moment) that reproduces the verdict. Read "verdict":
|
|
// PARTIAL means some rows could not be judged. Needs a gateway that serves POST /v1/identity/verify.
|
|
func (c *Client) IdentityVerify(ctx context.Context, req IdentityVerifyRequest) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/identity/verify", req, true)
|
|
}
|
|
|
|
// ComplianceCheck (1.7.0): your policy judged on an AERE Identity presentation -> COMPLIANT / NOT_COMPLIANT with the reasons, and with
|
|
// Record the record as an AIP-23 envelope without personal data. No claims come back.
|
|
func (c *Client) ComplianceCheck(ctx context.Context, req ComplianceCheckRequest) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/compliance/check", req, true)
|
|
}
|
|
|
|
func (c *Client) DataHead(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/data/head", nil, true)
|
|
}
|
|
func (c *Client) Validators(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/data/validators", nil, true)
|
|
}
|
|
func (c *Client) Anchors(ctx context.Context, limit int) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/data/anchors?limit="+strconv.Itoa(limit), nil, true)
|
|
}
|
|
func (c *Client) Anchor(ctx context.Context, height uint64) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/data/anchors/"+strconv.FormatUint(height, 10), nil, true)
|
|
}
|
|
|
|
// Finality returns the post-quantum finality point (finalized/safe = the parent of the last certified anchor), 2026-09-26.
|
|
func (c *Client) Finality(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/data/finality", nil, true)
|
|
}
|
|
func (c *Client) Transfers(ctx context.Context, address string, limit int) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/data/transfers?address="+url.QueryEscape(address)+"&limit="+strconv.Itoa(limit), nil, true)
|
|
}
|
|
func (c *Client) Notarize(ctx context.Context, hash string) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/notarize", map[string]string{"hash": hash}, true)
|
|
}
|
|
func (c *Client) ProofOf(ctx context.Context, hash string) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/notarize/"+esc(hash), nil, true)
|
|
}
|
|
|
|
// Proof: the proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps).
|
|
func (c *Client) Proof(ctx context.Context, hash string) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/proof/"+esc(hash), nil, true)
|
|
}
|
|
|
|
// ---- quantum readiness ----
|
|
|
|
// Readiness scans a domain's public TLS edge. Free without a key; attest=true notarizes the report digest and sends the key.
|
|
func (c *Client) Readiness(ctx context.Context, domain string, fresh, attest bool) (json.RawMessage, error) {
|
|
b := map[string]any{"domain": domain}
|
|
if fresh {
|
|
b["fresh"] = true
|
|
}
|
|
if attest {
|
|
b["attest"] = true
|
|
}
|
|
return c.jsonReq(ctx, "POST", "/pq/readiness", b, attest)
|
|
}
|
|
func (c *Client) ReadinessReport(ctx context.Context, domain string) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/pq/readiness/"+esc(domain), nil, false)
|
|
}
|
|
|
|
// PerimeterOptions: Hosts may be nil; Discover nil keeps the server default; Attest notarizes only a COMPLETE report.
|
|
type PerimeterOptions struct {
|
|
Hosts []string
|
|
Discover *bool
|
|
Attest bool
|
|
}
|
|
|
|
// ReadinessPerimeter: the whole public perimeter of a domain in one keyed report; call again while summary.complete is false.
|
|
func (c *Client) ReadinessPerimeter(ctx context.Context, domain string, o PerimeterOptions) (json.RawMessage, error) {
|
|
b := map[string]any{"domain": domain}
|
|
if o.Hosts != nil {
|
|
b["hosts"] = o.Hosts
|
|
}
|
|
if o.Discover != nil && !*o.Discover {
|
|
b["discover"] = false
|
|
}
|
|
if o.Attest {
|
|
b["attest"] = true
|
|
}
|
|
return c.jsonReq(ctx, "POST", "/pq/readiness/perimeter", b, true)
|
|
}
|
|
|
|
// PQFinality: AIP-21 per-block post-quantum finality. client: "besu", "nethermind" or "both" (with `agreement`). No key.
|
|
func (c *Client) PQFinality(ctx context.Context, block, network, client string) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/pq/finality/"+esc(block)+"?network="+url.QueryEscape(network)+"&client="+url.QueryEscape(client), nil, false)
|
|
}
|
|
|
|
// ---- audit log ----
|
|
|
|
// AuditLog: the paged JSON view of one UTC day (day "" = today), with the digest of the day file.
|
|
func (c *Client) AuditLog(ctx context.Context, day string, offset, limit int) (json.RawMessage, error) {
|
|
q := "?offset=" + strconv.Itoa(offset) + "&limit=" + strconv.Itoa(limit)
|
|
if day != "" {
|
|
q += "&day=" + url.QueryEscape(day)
|
|
}
|
|
return c.jsonReq(ctx, "GET", "/account/audit"+q, nil, true)
|
|
}
|
|
|
|
// AuditExport is a day as a file. For jsonl, Verified reports whether the sha256 of the bytes RECEIVED equals the stated
|
|
// digest (computed here, not taken on trust); for cef it is nil, because the digest is the one of the source file.
|
|
type AuditExport struct {
|
|
Day, Format, Digest string
|
|
Bytes []byte
|
|
Entries int
|
|
Verified *bool
|
|
}
|
|
|
|
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return "0x" + hex.EncodeToString(h[:]) }
|
|
|
|
func (c *Client) AuditExport(ctx context.Context, day, format string) (*AuditExport, error) {
|
|
if format == "" {
|
|
format = "jsonl"
|
|
}
|
|
p := "/account/audit?format=" + url.QueryEscape(format)
|
|
if day != "" {
|
|
p += "&day=" + url.QueryEscape(day)
|
|
}
|
|
r, err := c.do(ctx, "GET", p, nil, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.status < 200 || r.status >= 300 {
|
|
return nil, &Error{Status: r.status, Body: r.body, Path: "/account/audit"}
|
|
}
|
|
e := &AuditExport{Day: r.headers.Get("x-aere-day"), Format: format, Digest: r.headers.Get("x-aere-digest"), Bytes: r.body}
|
|
e.Entries, _ = strconv.Atoi(r.headers.Get("x-aere-entries"))
|
|
if format == "jsonl" {
|
|
ok := e.Digest != "" && e.Digest == sha256Hex(r.body)
|
|
e.Verified = &ok
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// AuditRetention reads the retention of the audit log with the API key (read-only). Retention is SET with a wallet
|
|
// signature in the console, never with the key: a stolen key must not be able to shorten the trail of its own use.
|
|
func (c *Client) AuditRetention(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/account/audit/retention", nil, true)
|
|
}
|
|
|
|
// KeptCheck: does an export you KEPT still match the day we hold ("held"), or the tombstone a deleted day left ("expired")?
|
|
type KeptCheck struct {
|
|
Day, State, Digest, Local, DeletedAt string
|
|
Matches bool
|
|
}
|
|
|
|
// AuditVerifyKept hashes YOUR bytes locally and compares them with the day we hold or with the tombstone of a deleted day
|
|
// (410). Any other failure is returned as an error, never as "does not match".
|
|
func (c *Client) AuditVerifyKept(ctx context.Context, day string, kept []byte) (*KeptCheck, error) {
|
|
local := sha256Hex(kept)
|
|
r, err := c.do(ctx, "GET", "/account/audit?format=jsonl&day="+url.QueryEscape(day), nil, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.status == 410 {
|
|
var t struct {
|
|
Expired *struct {
|
|
Digest string `json:"digest"`
|
|
DeletedAt string `json:"deletedAt"`
|
|
} `json:"expired"`
|
|
}
|
|
if json.Unmarshal(r.body, &t) == nil && t.Expired != nil && t.Expired.Digest != "" {
|
|
return &KeptCheck{Day: day, State: "expired", Digest: t.Expired.Digest, Local: local, Matches: local == t.Expired.Digest, DeletedAt: t.Expired.DeletedAt}, nil
|
|
}
|
|
}
|
|
if r.status < 200 || r.status >= 300 {
|
|
return nil, &Error{Status: r.status, Body: r.body, Path: "/account/audit"}
|
|
}
|
|
d := sha256Hex(r.body)
|
|
return &KeptCheck{Day: day, State: "held", Digest: d, Local: local, Matches: local == d}, nil
|
|
}
|
|
|
|
// ---- webhooks and gas sponsorship ----
|
|
|
|
func (c *Client) ListWebhooks(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/webhooks", nil, true)
|
|
}
|
|
func (c *Client) CreateWebhook(ctx context.Context, spec any) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/webhooks", spec, true)
|
|
}
|
|
func (c *Client) DeleteWebhook(ctx context.Context, id string) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "DELETE", "/webhooks/"+esc(id), nil, true)
|
|
}
|
|
func (c *Client) SponsorHealth(ctx context.Context) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "GET", "/sponsor/health", nil, true)
|
|
}
|
|
func (c *Client) SponsorCreateAccount(ctx context.Context, body any) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/sponsor/createAccount", body, true)
|
|
}
|
|
|
|
// SponsorCreatePQAccount: a smart account with a POST-QUANTUM owner (Falcon-512, 0x09-prefixed, 897 bytes), gas sponsored.
|
|
func (c *Client) SponsorCreatePQAccount(ctx context.Context, falconPubKeyHex string, salt uint64) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/sponsor/createPqAccount", map[string]any{"falconPubKey": falconPubKeyHex, "salt": salt}, true)
|
|
}
|
|
func (c *Client) SponsorExecute(ctx context.Context, body any) (json.RawMessage, error) {
|
|
return c.jsonReq(ctx, "POST", "/sponsor/execute", body, true)
|
|
}
|
|
|
|
// VerifyWebhook checks a webhook delivery's HMAC-SHA256 signature in constant time. rawBody must be the EXACT bytes
|
|
// received, not re-serialized JSON. Never act on a webhook without this.
|
|
func VerifyWebhook(rawBody []byte, signatureHeader, secret string) bool {
|
|
m := hmac.New(sha256.New, []byte(secret))
|
|
m.Write(rawBody)
|
|
want := []byte(hex.EncodeToString(m.Sum(nil)))
|
|
return subtle.ConstantTimeCompare(want, []byte(signatureHeader)) == 1
|
|
}
|