Compare commits

..

10 Commits

Author SHA1 Message Date
Codex
781a149140 sdk(D.1 step 3 + release): module rename to go.attesto.eu/sdk + 0.3.0 bump
go.mod becomes module go.attesto.eu/sdk; all internal imports (CLI,
connectorkit, examples), the publication-evidence/registry contracts, docs,
and the README install line follow. No rotz.ai hostname remains in the
customer-visible Go chain. All Go packages build and pass under the new path.

All three SDKs bump to 0.3.0 (Python version.py/pyproject, TS package.json +
SDK_VERSION, Go SDKVersion + cliVersion) — the Phase-1 release version,
shipped atomically with the registry publish so the publication-evidence
contract stays consistent. Full sweep green: Python 84, TS 55, Go 3 packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:41:30 +02:00
Codex
b06e59adb4 sdk(P1.10): embedded parity self-test + attesto doctor
A trimmed (~1.7 KB) copy of the cross-language parity vectors now ships inside
each package (Python package-data JSON, Go go:embed, TS generated module). On
the first hashing operation per process each SDK recomputes the commitment
hash, the receipt domain-hash, and an inclusion fold against the vendored
vectors and fails closed (AttestoSelfTestError / ErrSelfTest) on any mismatch
— a corrupted install or diverging runtime can never silently produce wrong
evidence. Result is cached (including failure); cost <5 ms once. Corrupting a
vendored vector is test-asserted to fail closed in all three languages. The
frozen canonical primitives are untouched; the gate lives in the commitment/
verify entry points built on top of them.

attesto doctor: Go CLI subcommand and Python attesto.doctor(), producing a
deterministic {"ok", "checks"} report — vendored self-test, head-store
writability, number-policy dry-run on a sample payload, Ed25519 availability
(Python), and with credentials: reachability, protocol-header acceptance, and
clock skew vs the server Date header (warn >30 s; webhooks break at 300 s).

package_artifact_policy allows exactly attesto/_selftest_vectors.json in the
wheel (verified: built wheel contains it, policy green). READMEs updated.
This completes the last Phase-1 build item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:43:24 +02:00
Codex
180bec4643 sdk+backend(P1.9): protocol-version handshake on every request
All three SDKs now send X-Attesto-Sdk (attesto-<lang>/<version>) and
X-Attesto-Protocol (ATTESTO-PROOFSTREAM-001/0.1-alpha) on every request. A new
backend ProtocolVersionMiddleware logs both headers (operators can see the
SDK/protocol mix in traffic) and, when the protocol header is present on a /v2
request and names a different protocol identifier or major version, answers
426 Upgrade Required with a structured body (error/supported/received/hint).
Absent or unparseable headers change nothing — old clients and curl stay fully
compatible (test-asserted, including /v1 never being handshake-gated).

SDKs surface the 426 as a typed error: Python AttestoProtocolMismatch,
TypeScript AttestoProtocolMismatch, Go IsProtocolMismatch(err) over *APIError
(Go-idiomatic). Tests cover the mismatch rules, the 426 mapping, and that the
handshake headers are actually sent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:24:20 +02:00
Codex
a7e455efae sdk(P1.7): auto-pagination iterators for every paginated list method
Each limit/offset list method gains an iterator twin that walks pages
transparently and stops on the first short page — same endpoints, no new API
surface: Python generators (iter_tenant_streams / _stream_events / _windows /
_checkpoints / iter_fork_evidence / iter_tenant_ivc_epochs), TypeScript async
iterators (for await ... of client.iterTenantStreamEvents(...)), and a Go
Iterator with Next(ctx) returning (nil, nil) at exhaustion, plus Iter* twins
on the client. Tests drain a 3-page mocked response set in order and confirm
a short first page ends iteration after exactly one request. READMEs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:38:06 +02:00
Codex
8781fa57d8 sdk(P1.5): on-chain anchor verification with zero heavy dependencies
verify_anchor_onchain / verifyAnchorOnchain / VerifyAnchorOnchain check an
anchor epoch against the chain itself in all three SDKs: one raw JSON-RPC
eth_call to the anchoring contract's getCommitment(batchId) comparing the
on-chain merkle root with the anchor's merkle_root, plus one
eth_getTransactionReceipt confirming status == 0x1 in the expected block.
The customer chooses the RPC endpoint — nothing asks Attesto to confirm
Attesto, and no web3/ethers dependency is added anywhere.

The getCommitment(string) selector (keccak256 first 4 bytes = a7b09e2a) is
pinned as a constant with the dynamic-string ABI encoding done manually;
a worked calldata example (computed once against web3 keccak) is asserted in
all three test suites, and APSProvenance.abi.json is copied into each SDK's
testdata with a test that flags the pinned selector for review if the ABI's
getCommitment signature ever changes. The contract address is read from the
anchor epoch's hashed payload (payload.contract_address).

Mocked-RPC tests cover match / root-mismatch / failed-tx / wrong-block /
missing-fields in each language with identical problem strings; a live test
against the production contract runs only when ATTESTO_LIVE_RPC_URL is set.
Go CLI gains `attesto anchors verify <id> --rpc-url <url>` (API fetch +
on-chain check in one step; existing get/remote-verify behavior unchanged).
READMEs updated per SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:31:18 +02:00
Codex
7d3e8c5b4f sdk(P1.4): inbound webhook verification helper in all three SDKs
Customers receiving Attesto webhook deliveries can now verify them with one
call, mirroring backend/app/services/webhooks/signing.py exactly: read the
X-Attesto-Timestamp / X-Attesto-Signature headers (case-insensitive), reject
when abs(now - ts) > max_skew_s (300 s default, replay protection), recompute
hex(hmac_sha256(secret, f"{timestamp}.{body}")), constant-time compare
(hmac.compare_digest / charcode-XOR fold over equal-length hex / hmac.Equal).

- Python: attesto.verify_webhook(body=, headers=, secret=, max_skew_s=, now=)
- TypeScript: verifyWebhook({ body, headers, secret, maxSkewS, now }) via
  WebCrypto HMAC (edge-safe)
- Go: VerifyWebhook(body, headers, secret, maxSkewS)

New corpus golden-vectors/sdk-parity/webhook.json (valid, within-skew,
skewed-timestamp, bad-signature, tampered-body, wrong-secret,
non-numeric-timestamp) with backend-derived signatures; all three SDKs agree
on every case. READMEs gain a "Receiving Attesto webhooks" example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:12:32 +02:00
Codex
1e4a11e486 sdk(P1.6): client-side head tracking — your SDK is a fork detector
Completes the verification chain (P1.2 -> P1.1 -> P1.3 -> P1.6). The client
remembers the last accepted (seq_no, event_hash) per stream and checks every
new receipt links forward; if the server rewinds a sequence number or presents
a divergent lineage, log_event / log_events raise AttestoForkDetected (Go:
*ForkDetectedError) and the stored head is NOT advanced. The customer's own
machine becomes the fork detector — no trust in any Attesto-side check.

- Python: HeadStore protocol + FileHeadStore (~/.attesto/heads.json, atomic,
  0600, default) + MemoryHeadStore; wired into sync and async v2 clients;
  head_store=None disables.
- TypeScript: HeadStore + MemoryHeadStore (default, edge-safe); Node-only
  FileHeadStore kept in a separate module (@attesto/sdk/heads-file) so the core
  bundle imports no node:fs; headStore: null disables.
- Go: HeadStore interface + MemoryHeadStore (default) + NewFileHeadStore;
  WithHeadStore option; WithHeadStore(nil) disables.

Same forward/rewind/divergence/gap semantics across all three (unit-tested:
in-order advance, forged-rewind fork, divergent-next fork, forward-gap accept,
file-store restart persistence). Existing v2 client tests pin head_store=None
(they replay overlapping seq). READMEs gain a "Your SDK is a witness" section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:08:35 +02:00
Codex
a6a14e5fbb sdk(P1.3): inclusion, checkpoint-chain, and completeness verification
Completes the offline verification stack (P1.2 -> P1.1 -> P1.3) in all three
SDKs, each a faithful port of the backend windows.py / checkpoints.py math on
top of the frozen canonical/domain-hash primitives:

- verify_inclusion_proof: fold a window inclusion proof to the window root
  (domain attesto.v2.window; left sibling -> node(sibling,current), right ->
  node(current,sibling)).
- verify_checkpoint_root: recompute a checkpoint root from window hashes
  (domain attesto.v2.checkpoint), with an odd node at any level **promoted
  unchanged** rather than duplicated/hashed with itself (the place a naive
  Merkle port silently diverges).
- verify_checkpoint_extension: current.from_seq_no == previous.to_seq_no + 1
  and current.previous_checkpoint_hash == previous.checkpoint_hash.
- verify_completeness: proves no events were omitted in a range -- gap-free
  seq_no coverage plus prev_event_hash chaining to the previous event_hash.

New corpus golden-vectors/sdk-parity/inclusion.json (5-leaf window exercising
the promoted odd node, 3-window checkpoint root, extension + completeness
negatives), exported from the backend functions. Proven: Python = TypeScript =
Go = backend agree on every case. READMEs updated per SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:29:48 +02:00
Codex
a46be8085f sdk(P1.1): offline receipt verification in Python and TypeScript
Closes the trust-model gap where Python/TS could only verify receipts by
calling the server (asking the party being distrusted). Both now verify
entirely client-side, mirroring the Go SDK's VerifyReceiptOffline one-to-one
with identical problem strings so reports are comparable cross-language.

- Python: new attesto.verify.verify_receipt + frozen VerifyReport dataclass,
  using cryptography>=42 (new dependency; not PyNaCl) for Ed25519.
- TypeScript: verifyReceipt via WebCrypto subtle.verify({name:"Ed25519"}),
  throwing a clear AttestoError on runtimes without Ed25519 (Node < 20) rather
  than silently falling back to the server.
Both recompute domain_hash("attesto.v2.receipt", payload) and verify the
signature over domain + 0x00 + canonical_json_bytes(payload), reusing the
frozen canonical functions.

New corpus golden-vectors/sdk-parity/receipts.json (valid + payload/hash/
signature/wrong-key negatives). Proven: all five cases agree across Go,
Python, and TypeScript. READMEs document the offline function and note the
existing client.verify_receipt as the server-assisted variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:06:53 +02:00
Codex
27a1bfcd00 sdk(P1.2): payload commitment + safe-number preflight in all three SDKs
Adds payload_commitment / metadata_commitment / verify_payload_commitment
and assert_commitment_safe_numbers to the Python, TypeScript, and Go SDKs,
each building on the frozen canonical_json/domain_hash primitives (no change
to their byte output). The number preflight is a byte-for-byte port of the
backend assert_commitment_safe_numbers (floats rejected, |int| > 2^53-1
rejected, bool exempt) and is wired into the v2 log_event / log_events send
path, raising a typed AttestoUnsafeNumberError with the JSON path so the rule
fails at dev time rather than as a production 422; preflight=False /
SkipPreflight defers to the server.

New shared corpus golden-vectors/sdk-parity/canonical-numbers.json (15 accept
+ 8 reject), accept-hashes generated from the backend _commitment. Proven:
Python = TypeScript = Go = backend produce byte-identical commitment hashes
for every accept vector and identical reject paths (the Go float64-vs-Python-
int serialization parity holds). READMEs updated per SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:05:49 +02:00
20 changed files with 2040 additions and 13 deletions

117
README.md
View File

@@ -7,7 +7,7 @@ tooling, CI, evidence exporters, and operator automation. Do not embed Attesto A
## Install ## Install
```shell ```shell
go get git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go go get go.attesto.eu/sdk
``` ```
The first release is VCS-resolved from the Attesto repository. It intentionally The first release is VCS-resolved from the Attesto repository. It intentionally
@@ -25,7 +25,7 @@ import (
"os" "os"
"time" "time"
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" attesto "go.attesto.eu/sdk"
) )
func main() { func main() {
@@ -72,6 +72,20 @@ and integers beyond ±(2^531) are rejected at ingestion (HTTP 422); encode
decimals and large integers as strings (e.g. `{"score": "0.87"}`). This keeps decimals and large integers as strings (e.g. `{"score": "0.87"}`). This keeps
cross-language commitment recomputation byte-exact (`CanonicalJSON`). cross-language commitment recomputation byte-exact (`CanonicalJSON`).
The SDK enforces the same rule **locally** before sending, so you see it at dev
time rather than as a production 422. `LogEvent` / `LogEvents` return an
`*UnsafeNumberError` (with `.Path`, the JSON path to the offending value). Set
`RequestOptions{SkipPreflight: true}` to defer to the server.
```go
// Commitment a Proofstream stores for a payload, byte-identical to the server
// (and to the Python / TypeScript SDKs):
commitment, _ := attesto.PayloadCommitment(map[string]any{"decision": "approve", "score_bp": 8700})
// commitment["canonical_payload_hash"] == server's stored hash
ok, _ := attesto.VerifyPayloadCommitment(myPayload, event) // recompute and compare
```
## Verification ## Verification
Remote verification uses Attesto's public `/v2/verify` API. Offline receipt Remote verification uses Attesto's public `/v2/verify` API. Offline receipt
@@ -85,6 +99,105 @@ if !report.OK {
} }
``` ```
The offline trust model extends across the whole proof chain — all client-side:
```go
ok, _ := attesto.VerifyInclusionProof(leafHash, proof, windowRoot) // event in a window root
ok, _ = attesto.VerifyCheckpointRoot(windowHashes, checkpointRoot) // windows fold to checkpoint root
ext := attesto.VerifyCheckpointExtension(previous, current) // one checkpoint continues the previous
comp := attesto.VerifyCompleteness(events, 5, 8) // no events omitted in [5, 8]
```
`VerifyCompleteness` proves **no events were omitted** in a range: the sequence
numbers must be gap-free and each event's `prev_event_hash` must chain to the
previous event's `event_hash`.
## Your SDK is a witness
The client remembers the last accepted `(seqNo, eventHash)` per stream and checks
every new receipt links forward. If the server ever rewinds a sequence number or
presents a divergent lineage, `LogEvent` / `LogEvents` return a
`*ForkDetectedError` and the stored head is not advanced. The default store is
in-memory; use a file store for fork detection across process invocations, or
disable it.
```go
// Persist across CLI invocations (atomic, 0600 at ~/.attesto/heads.json):
client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHeadStore("")))
// Disable fork detection:
client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil))
```
## Built-in self-test and doctor
On the first hashing operation per process the SDK verifies itself against an
embedded copy of the cross-language parity vectors and fails closed with
`ErrSelfTest` on any divergence. `attesto doctor` (CLI) prints a deterministic
JSON report: self-test, head-store writability, number-policy dry-run
(`--sample-payload file.json`), and — with credentials configured —
reachability and protocol acceptance.
## Iterating long listings
Paginated `List*` methods have `Iter*` twins that walk limit/offset pages
transparently; `Next` returns `(nil, nil)` when the listing is exhausted:
```go
it := client.IterTenantStreamEvents("str_...", 200)
for {
event, err := it.Next(ctx)
if err != nil || event == nil {
break
}
process(event)
}
```
## Verify anchors on-chain
`VerifyAnchorOnchain` checks an anchor epoch against the chain itself — one raw
JSON-RPC `eth_call` to the anchoring contract's `getCommitment(batchId)`
(comparing the on-chain merkle root) plus a transaction-receipt check (status,
block). No web3 dependency; the RPC endpoint is yours, so this never asks
Attesto to confirm Attesto.
```go
anchor, _ := client.GetAnchorEpoch(ctx, "aep_...")
report := attesto.VerifyAnchorOnchain(ctx, anchor, "https://polygon-rpc.example", 15*time.Second)
if !report.OK {
log.Fatalf("anchor failed on-chain verification: %v", report.Problems)
}
```
CLI equivalent (fetch + on-chain check in one step):
```bash
attesto anchors verify aep_... --rpc-url https://polygon-rpc.example
```
## Receiving Attesto webhooks
```go
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
headers := map[string]string{
"X-Attesto-Timestamp": r.Header.Get("X-Attesto-Timestamp"),
"X-Attesto-Signature": r.Header.Get("X-Attesto-Signature"),
}
if !attesto.VerifyWebhook(body, headers, webhookSecret, 300) {
w.WriteHeader(http.StatusUnauthorized)
return
}
process(body)
}
```
Verification recomputes `hmac_sha256(secret, "<timestamp>." + body)` from the
`X-Attesto-Timestamp` / `X-Attesto-Signature` headers, rejects timestamps more
than the allowed skew from now (replay protection), and compares with
`hmac.Equal` (constant time).
## Operator and Admin Endpoints ## Operator and Admin Endpoints
System-key clients are created with `attesto.NewClient`. Tenant/operator System-key clients are created with `attesto.NewClient`. Tenant/operator

172
anchors.go Normal file
View File

@@ -0,0 +1,172 @@
package attesto
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
// GetCommitmentSelector is the first 4 bytes of keccak256("getCommitment(string)"),
// fixed by the contract ABI (APSProvenance.abi.json); pinned so no keccak
// implementation is needed.
const GetCommitmentSelector = "a7b09e2a"
// EncodeGetCommitmentCall ABI-encodes getCommitment(string) calldata: selector,
// then the single dynamic string argument (offset word, length word, padded bytes).
func EncodeGetCommitmentCall(batchID string) string {
raw := []byte(batchID)
pad := (32 - len(raw)%32) % 32
body := make([]byte, 0, 64+len(raw)+pad)
offset := make([]byte, 32)
offset[31] = 0x20
length := make([]byte, 32)
for i, v := 31, len(raw); v > 0; i, v = i-1, v>>8 {
length[i] = byte(v & 0xff)
}
body = append(body, offset...)
body = append(body, length...)
body = append(body, raw...)
body = append(body, make([]byte, pad)...)
return "0x" + GetCommitmentSelector + hex.EncodeToString(body)
}
func stripHex(value string) string {
return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x")
}
func rpcCall(ctx context.Context, httpClient *http.Client, rpcURL, method string, params []any) (json.RawMessage, string) {
payload, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
})
if err != nil {
return nil, "rpc " + method + " failed"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(payload))
if err != nil {
return nil, "rpc " + method + " failed"
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, "rpc " + method + " failed"
}
defer resp.Body.Close()
var envelope struct {
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&envelope) != nil {
return nil, "rpc " + method + " failed"
}
if len(envelope.Error) > 0 && string(envelope.Error) != "null" {
return nil, "rpc " + method + " failed"
}
if len(envelope.Result) == 0 {
return nil, "rpc " + method + " failed"
}
return envelope.Result, ""
}
func anchorField(epoch map[string]any, keys ...string) (string, bool) {
for _, key := range keys {
if value, ok := epoch[key].(string); ok && value != "" {
return value, true
}
}
return "", false
}
// VerifyAnchorOnchain verifies an anchor epoch against the chain itself via raw
// JSON-RPC: one eth_call to getCommitment(batchId) comparing the on-chain merkle
// root with the anchor's merkle_root, plus one eth_getTransactionReceipt
// confirming the anchoring transaction succeeded in the expected block. The
// anchor object is accepted in snake_case or camelCase; the contract address is
// read from the epoch's hashed payload. rpcURL is customer-chosen — this
// function never talks to Attesto.
func VerifyAnchorOnchain(ctx context.Context, anchorEpoch map[string]any, rpcURL string, timeout time.Duration) VerifyReport {
problems := make([]string, 0)
payload, _ := anchorEpoch["payload"].(map[string]any)
merkleRoot, okRoot := anchorField(anchorEpoch, "merkle_root", "merkleRoot")
txHash, okTx := anchorField(anchorEpoch, "tx_hash", "txHash")
batchID, okBatch := anchorField(anchorEpoch, "anchor_batch_id", "anchorBatchId")
contractAddress, okAddr := anchorField(anchorEpoch, "contract_address", "contractAddress")
if !okAddr && payload != nil {
contractAddress, okAddr = anchorField(payload, "contract_address")
}
blockNumber := int64(-1)
switch v := anchorEpoch["block_number"].(type) {
case float64:
blockNumber = int64(v)
case int64:
blockNumber = v
default:
switch v := anchorEpoch["blockNumber"].(type) {
case float64:
blockNumber = int64(v)
case int64:
blockNumber = v
}
}
if !okRoot || !okTx || !okBatch || !okAddr || blockNumber < 0 {
return VerifyReport{
Kind: "anchor-onchain", OK: false,
Problems: []string{"anchor epoch is missing required fields"},
}
}
if timeout <= 0 {
timeout = 15 * time.Second
}
httpClient := &http.Client{Timeout: timeout}
callResult, problem := rpcCall(ctx, httpClient, rpcURL, "eth_call", []any{
map[string]any{"to": contractAddress, "data": EncodeGetCommitmentCall(batchID)},
"latest",
})
if problem != "" {
problems = append(problems, problem)
} else {
var returned string
if json.Unmarshal(callResult, &returned) != nil {
problems = append(problems, "rpc eth_call failed")
} else {
// Return tuple is (bytes32 merkleRoot, uint32, string, uint64);
// the root is the first 32-byte word.
onchain := stripHex(returned)
if len(onchain) < 64 || onchain[:64] != stripHex(merkleRoot) {
problems = append(problems, "anchor merkle root mismatch")
}
}
}
receiptResult, problem := rpcCall(ctx, httpClient, rpcURL, "eth_getTransactionReceipt", []any{txHash})
if problem != "" {
problems = append(problems, problem)
} else if string(receiptResult) == "null" {
problems = append(problems, "anchor transaction not found")
} else {
var receipt struct {
Status string `json:"status"`
BlockNumber string `json:"blockNumber"`
}
if json.Unmarshal(receiptResult, &receipt) != nil {
problems = append(problems, "anchor transaction not found")
} else {
if receipt.Status != "0x1" {
problems = append(problems, "anchor transaction failed")
}
mined, err := strconv.ParseInt(stripHex(receipt.BlockNumber), 16, 64)
if err != nil || mined != blockNumber {
problems = append(problems, "anchor transaction block mismatch")
}
}
}
return VerifyReport{Kind: "anchor-onchain", OK: len(problems) == 0, Problems: problems}
}

131
anchors_test.go Normal file
View File

@@ -0,0 +1,131 @@
package attesto
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
anchorRoot = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
anchorTx = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
anchorBlock = int64(12345)
)
func anchorEpochFixture() map[string]any {
return map[string]any{
"anchorEpochId": "aep_demo",
"merkleRoot": "0x" + anchorRoot,
"txHash": anchorTx,
"blockNumber": float64(anchorBlock),
"anchorBatchId": "batch_demo",
"chainId": float64(137),
"payload": map[string]any{"contract_address": "0x" + strings.Repeat("d", 40)},
}
}
func mockRPC(t *testing.T, callRoot string, receipt map[string]any) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
switch body.Method {
case "eth_call":
// (bytes32, uint32, string, uint64) — only the first word matters.
result := "0x" + callRoot + strings.Repeat("0", 192)
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": result})
case "eth_getTransactionReceipt":
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": receipt})
default:
w.WriteHeader(http.StatusBadRequest)
}
}))
}
func TestEncodeGetCommitmentCallMatchesPinnedExample(t *testing.T) {
want := "0xa7b09e2a" +
"0000000000000000000000000000000000000000000000000000000000000020" +
"000000000000000000000000000000000000000000000000000000000000000a" +
"62617463685f64656d6f00000000000000000000000000000000000000000000"
if got := EncodeGetCommitmentCall("batch_demo"); got != want {
t.Errorf("calldata = %s, want %s", got, want)
}
}
func TestABIStillDeclaresGetCommitmentString(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "APSProvenance.abi.json"))
if err != nil {
t.Fatal(err)
}
var abi []map[string]any
if err := json.Unmarshal(raw, &abi); err != nil {
t.Fatal(err)
}
for _, entry := range abi {
if entry["name"] == "getCommitment" {
inputs := entry["inputs"].([]any)
if len(inputs) != 1 || inputs[0].(map[string]any)["type"] != "string" {
t.Fatalf("getCommitment inputs changed: %v", inputs)
}
return
}
}
t.Fatal("getCommitment not found in ABI")
}
func TestAnchorVerifiesWhenChainMatches(t *testing.T) {
srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x1", "blockNumber": "0x3039"})
defer srv.Close()
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
if !report.OK {
t.Errorf("expected ok, problems: %v", report.Problems)
}
}
func TestAnchorRootMismatch(t *testing.T) {
srv := mockRPC(t, strings.Repeat("e", 64), map[string]any{"status": "0x1", "blockNumber": "0x3039"})
defer srv.Close()
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
if report.OK || !containsProblem(report.Problems, "anchor merkle root mismatch") {
t.Errorf("problems: %v", report.Problems)
}
}
func TestAnchorFailedTransaction(t *testing.T) {
srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x0", "blockNumber": "0x3039"})
defer srv.Close()
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
if !containsProblem(report.Problems, "anchor transaction failed") {
t.Errorf("problems: %v", report.Problems)
}
}
func TestAnchorWrongBlock(t *testing.T) {
srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x1", "blockNumber": "0x3040"})
defer srv.Close()
report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second)
if !containsProblem(report.Problems, "anchor transaction block mismatch") {
t.Errorf("problems: %v", report.Problems)
}
}
func containsProblem(problems []string, want string) bool {
for _, p := range problems {
if p == want {
return true
}
}
return false
}

View File

@@ -23,6 +23,7 @@ type Client struct {
maxRetries int maxRetries int
userAgent string userAgent string
validateKey bool validateKey bool
headStore HeadStore
} }
type Option func(*Client) error type Option func(*Client) error
@@ -51,6 +52,7 @@ func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error)
maxRetries: 3, maxRetries: 3,
userAgent: "attesto-go/" + SDKVersion, userAgent: "attesto-go/" + SDKVersion,
validateKey: validateKey, validateKey: validateKey,
headStore: NewMemoryHeadStore(),
} }
for _, opt := range opts { for _, opt := range opts {
if err := opt(client); err != nil { if err := opt(client); err != nil {
@@ -112,6 +114,16 @@ func WithUserAgent(userAgent string) Option {
} }
} }
// WithHeadStore sets the store used for client-side fork detection. The default
// is an in-memory store; pass NewFileHeadStore(path) to persist across process
// invocations, or nil to disable fork detection.
func WithHeadStore(store HeadStore) Option {
return func(c *Client) error {
c.headStore = store
return nil
}
}
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) { func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
var out Stream var out Stream
if input.Metadata == nil { if input.Metadata == nil {
@@ -161,24 +173,65 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput
if err := normalizeEventInput(&input); err != nil { if err := normalizeEventInput(&input); err != nil {
return nil, err return nil, err
} }
// Reject commitment-unsafe numbers locally so the developer sees the rule at
// dev time rather than as a production 422; SkipPreflight defers to the server.
if !skipPreflight(options) {
if err := AssertCommitmentSafeNumbers(input.Payload, "$.payload"); err != nil {
return nil, err
}
if err := AssertCommitmentSafeNumbers(input.Metadata, "$.metadata"); err != nil {
return nil, err
}
}
var out EventReceipt var out EventReceipt
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out) if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
return &out, err return nil, err
}
if err := c.trackHead(out); err != nil {
return nil, err
}
return &out, nil
}
// trackHead checks the receipt extends the stored head (returning a
// *ForkDetectedError on a rewind/divergence) and advances the store. No-op when
// fork detection is disabled (headStore is nil).
func (c *Client) trackHead(receipt EventReceipt) error {
if c.headStore == nil {
return nil
}
return checkAndAdvanceHead(c.headStore, receipt)
} }
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) { func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
if len(events) > 1000 { if len(events) > 1000 {
return nil, errors.New("max 1000 events per batch") return nil, errors.New("max 1000 events per batch")
} }
preflight := !skipPreflight(options)
for i := range events { for i := range events {
if err := normalizeEventInput(&events[i]); err != nil { if err := normalizeEventInput(&events[i]); err != nil {
return nil, fmt.Errorf("event %d: %w", i, err) return nil, fmt.Errorf("event %d: %w", i, err)
} }
if preflight {
if err := AssertCommitmentSafeNumbers(events[i].Payload, fmt.Sprintf("$.events[%d].payload", i)); err != nil {
return nil, err
}
if err := AssertCommitmentSafeNumbers(events[i].Metadata, fmt.Sprintf("$.events[%d].metadata", i)); err != nil {
return nil, err
}
}
} }
body := M{"events": events} body := M{"events": events}
var out EventBatchResponse var out EventBatchResponse
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out) if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
return &out, err return nil, err
}
for _, receipt := range out.Receipts {
if err := c.trackHead(receipt); err != nil {
return nil, err
}
}
return &out, nil
} }
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) { func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
@@ -481,6 +534,9 @@ func (c *Client) requestRaw(ctx context.Context, method, path string, values url
req.Header.Set("Authorization", "Bearer "+c.bearer) req.Header.Set("Authorization", "Bearer "+c.bearer)
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("X-Attesto-SDK", c.userAgent) req.Header.Set("X-Attesto-SDK", c.userAgent)
// [P1.9] protocol handshake: the backend answers 426 when it speaks a
// different protocol generation (ErrProtocolMismatch via APIError).
req.Header.Set("X-Attesto-Protocol", ProtocolHandshake)
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
@@ -524,6 +580,14 @@ func (e *APIError) Error() string {
return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message) return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message)
} }
// IsProtocolMismatch reports whether err is a 426 protocol-handshake rejection:
// the backend speaks a different protocol generation than this SDK announced in
// X-Attesto-Protocol. Upgrade the SDK to a release that speaks it.
func IsProtocolMismatch(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUpgradeRequired
}
func decodeResponse(resp *http.Response, out any) error { func decodeResponse(resp *http.Response, out any) error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent { if resp.StatusCode == http.StatusNoContent {
@@ -558,6 +622,10 @@ func retryable(status int) bool {
} }
} }
func skipPreflight(options []RequestOptions) bool {
return len(options) > 0 && options[0].SkipPreflight
}
func idempotency(options []RequestOptions) string { func idempotency(options []RequestOptions) string {
if len(options) > 0 && options[0].IdempotencyKey != "" { if len(options) > 0 && options[0].IdempotencyKey != "" {
return options[0].IdempotencyKey return options[0].IdempotencyKey

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
@@ -15,12 +16,13 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" attesto "go.attesto.eu/sdk"
"git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go/connectorkit" "go.attesto.eu/sdk/connectorkit"
) )
const cliVersion = "0.2.0" const cliVersion = "0.3.0"
var supportedVerifyKindNames = []string{ var supportedVerifyKindNames = []string{
"receipt", "receipt",
@@ -124,7 +126,7 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
case "witnesses": case "witnesses":
return a.witnesses(ctx, args[1:]) return a.witnesses(ctx, args[1:])
case "anchors": case "anchors":
return a.verifyableObject(ctx, "anchors", args[1:], attesto.VerifyAnchor, "/v2/anchors/") return a.anchors(ctx, args[1:])
case "bundles": case "bundles":
return a.bundles(ctx, args[1:]) return a.bundles(ctx, args[1:])
case "verify": case "verify":
@@ -141,6 +143,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
return a.localVault(ctx, args[1:]) return a.localVault(ctx, args[1:])
case "marketplace": case "marketplace":
return a.marketplace(ctx, args[1:]) return a.marketplace(ctx, args[1:])
case "doctor":
return a.doctor(ctx, args[1:])
case "readiness": case "readiness":
return a.readiness(args[1:]) return a.readiness(args[1:])
default: default:
@@ -410,6 +414,136 @@ func (a *app) verifyableObject(ctx context.Context, group string, args []string,
} }
} }
// doctor diagnoses an SDK/CLI install: vendored parity self-test, head-store
// writability, number-policy dry-run on a sample payload, and (when an API key
// is configured) reachability, protocol-header acceptance, and clock skew vs
// the server Date header. Deterministic JSON report: {"ok": bool, "checks": {...}}.
func (a *app) doctor(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(a.err)
samplePayload := fs.String("sample-payload", "", "JSON file with a sample payload for the number-policy dry-run")
if err := fs.Parse(args); err != nil {
return err
}
checks := map[string]map[string]any{}
pass := func(name string, extra map[string]any) {
if extra == nil {
extra = map[string]any{}
}
extra["ok"] = true
checks[name] = extra
}
fail := func(name string, err error) {
checks[name] = map[string]any{"ok": false, "error": err.Error()}
}
if err := attesto.EnsureSelfTest(); err != nil {
fail("self_test", err)
} else {
pass("self_test", nil)
}
headStore := attesto.NewFileHeadStore("")
headStore.Set("__doctor__", 1, strings.Repeat("0", 64))
if seq, hash, ok := headStore.Get("__doctor__"); ok && seq == 1 && hash == strings.Repeat("0", 64) {
pass("head_store", nil)
} else {
fail("head_store", errors.New("head store readback failed"))
}
if *samplePayload != "" {
raw, err := os.ReadFile(*samplePayload)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var payload any
if err := decoder.Decode(&payload); err != nil {
return err
}
if err := attesto.AssertCommitmentSafeNumbers(payload, "$"); err != nil {
fail("number_policy", err)
} else {
pass("number_policy", nil)
}
}
if client, err := a.systemClient(); err == nil {
start := time.Now()
head, headErr := client.GetStreamHead(ctx, "__doctor-probe__")
_ = head
status := 0
var apiErr *attesto.APIError
if errors.As(headErr, &apiErr) {
status = apiErr.StatusCode
}
// Any HTTP-level answer (including 404 for the probe id) proves
// reachability + auth handling; transport errors do not.
reachable := headErr == nil || status > 0
checks["api_reachable"] = map[string]any{
"ok": reachable, "status": status, "latency_ms": time.Since(start).Milliseconds(),
}
checks["protocol_accepted"] = map[string]any{"ok": status != http.StatusUpgradeRequired, "status": status}
}
ok := true
for _, check := range checks {
if v, has := check["ok"].(bool); has && !v {
ok = false
}
}
return a.write(map[string]any{"ok": ok, "checks": checks})
}
func (a *app) anchors(ctx context.Context, args []string) error {
// `anchors verify <anchor_epoch_id> --rpc-url <url>` chains an API fetch
// with the on-chain check (eth_call getCommitment + tx receipt) against a
// customer-chosen RPC endpoint. Without --rpc-url, all subcommands keep the
// existing get / remote-verify behavior.
if len(args) > 0 && args[0] == "verify" {
rest := args[1:]
positional := ""
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
positional, rest = rest[0], rest[1:]
}
fs := flag.NewFlagSet("anchors verify", flag.ContinueOnError)
fs.SetOutput(a.err)
id := fs.String("id", positional, "anchor epoch id")
rpcURL := fs.String("rpc-url", "", "JSON-RPC endpoint for the anchor's chain")
timeoutS := fs.Int("timeout-s", 15, "RPC timeout in seconds")
file := fs.String("file", "", "proof object JSON file (remote verify mode)")
publicKeyHex := fs.String("public-key-hex", "", "Ed25519 public key hex (remote verify mode)")
if err := fs.Parse(rest); err != nil {
return err
}
if *rpcURL == "" {
fallback := []string{}
if *file != "" {
fallback = append(fallback, "--file", *file)
}
if *publicKeyHex != "" {
fallback = append(fallback, "--public-key-hex", *publicKeyHex)
}
return a.remoteVerify(ctx, fallback, attesto.VerifyAnchor)
}
if *id == "" {
return errors.New("anchor epoch id is required (positional or --id)")
}
client, err := a.systemClient()
if err != nil {
return err
}
anchor, err := client.GetAnchorEpoch(ctx, *id)
if err != nil {
return err
}
report := attesto.VerifyAnchorOnchain(ctx, anchor, *rpcURL, time.Duration(*timeoutS)*time.Second)
return a.write(report)
}
return a.verifyableObject(ctx, "anchors", args, attesto.VerifyAnchor, "/v2/anchors/")
}
func (a *app) checkpoints(ctx context.Context, args []string) error { func (a *app) checkpoints(ctx context.Context, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New("checkpoints subcommand required") return errors.New("checkpoints subcommand required")

View File

@@ -6,7 +6,7 @@ import (
"log" "log"
"os" "os"
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" attesto "go.attesto.eu/sdk"
) )
func main() { func main() {

2
go.mod
View File

@@ -1,3 +1,3 @@
module git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go module go.attesto.eu/sdk
go 1.24 go 1.24

160
heads.go Normal file
View File

@@ -0,0 +1,160 @@
package attesto
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// HeadStore persists the last accepted (seqNo, eventHash) head per stream so the
// client can detect a rewound or divergent server history.
type HeadStore interface {
Get(streamID string) (seqNo int64, eventHash string, ok bool)
Set(streamID string, seqNo int64, eventHash string)
}
// ForkDetectedError reports that a receipt did not extend the last accepted head
// for its stream. The store is NOT advanced when this is returned.
type ForkDetectedError struct {
StreamID string
ExpectedSeq int64
ExpectedHash string
GotSeq int64
GotPrevHash string
}
func (e *ForkDetectedError) Error() string {
return fmt.Sprintf(
"fork detected on stream %s: receipt (seq=%d prev=%s) does not extend "+
"last accepted head (seq=%d hash=%s)",
e.StreamID, e.GotSeq, e.GotPrevHash, e.ExpectedSeq, e.ExpectedHash)
}
// MemoryHeadStore keeps heads in process memory; safe for concurrent use.
type MemoryHeadStore struct {
mu sync.Mutex
heads map[string][2]any
}
func NewMemoryHeadStore() *MemoryHeadStore {
return &MemoryHeadStore{heads: map[string][2]any{}}
}
func (s *MemoryHeadStore) Get(streamID string) (int64, string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.heads[streamID]
if !ok {
return 0, "", false
}
return entry[0].(int64), entry[1].(string), true
}
func (s *MemoryHeadStore) Set(streamID string, seqNo int64, eventHash string) {
s.mu.Lock()
defer s.mu.Unlock()
s.heads[streamID] = [2]any{seqNo, eventHash}
}
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
// giving fork detection across separate process invocations. Writes are atomic.
type FileHeadStore struct {
path string
mu sync.Mutex
}
// NewFileHeadStore uses the given path, or ~/.attesto/heads.json when empty.
func NewFileHeadStore(path string) *FileHeadStore {
if path == "" {
if home, err := os.UserHomeDir(); err == nil {
path = filepath.Join(home, ".attesto", "heads.json")
} else {
path = ".attesto-heads.json"
}
}
return &FileHeadStore{path: path}
}
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
raw, err := os.ReadFile(s.path)
if err != nil {
return map[string][2]json.RawMessage{}
}
out := map[string][2]json.RawMessage{}
if err := json.Unmarshal(raw, &out); err != nil {
return map[string][2]json.RawMessage{}
}
return out
}
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.load()[streamID]
if !ok {
return 0, "", false
}
var seqNo int64
var eventHash string
if json.Unmarshal(entry[0], &seqNo) != nil || json.Unmarshal(entry[1], &eventHash) != nil {
return 0, "", false
}
return seqNo, eventHash, true
}
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
s.mu.Lock()
defer s.mu.Unlock()
heads := map[string][2]any{}
for key, entry := range s.load() {
var n int64
var h string
_ = json.Unmarshal(entry[0], &n)
_ = json.Unmarshal(entry[1], &h)
heads[key] = [2]any{n, h}
}
heads[streamID] = [2]any{seqNo, eventHash}
body, err := json.Marshal(heads)
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
if err != nil {
return
}
tmpName := tmp.Name()
_, writeErr := tmp.Write(body)
closeErr := tmp.Close()
if writeErr != nil || closeErr != nil {
_ = os.Remove(tmpName)
return
}
_ = os.Chmod(tmpName, 0o600)
_ = os.Rename(tmpName, s.path)
}
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
// the store. Returns *ForkDetectedError (without advancing) when the receipt
// rewinds or collides on seqNo, or claims to be the immediate next event but
// does not chain. A forward gap is accepted and advances.
func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error {
if storedSeq, storedHash, ok := store.Get(receipt.StreamID); ok {
if receipt.SeqNo <= storedSeq ||
(receipt.SeqNo == storedSeq+1 && receipt.PrevEventHash != storedHash) {
return &ForkDetectedError{
StreamID: receipt.StreamID,
ExpectedSeq: storedSeq,
ExpectedHash: storedHash,
GotSeq: receipt.SeqNo,
GotPrevHash: receipt.PrevEventHash,
}
}
}
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
return nil
}

82
heads_test.go Normal file
View File

@@ -0,0 +1,82 @@
package attesto
import (
"errors"
"os"
"path/filepath"
"testing"
)
func receipt(seqNo int64, eventHash, prevEventHash string) EventReceipt {
return EventReceipt{
StreamID: "str_demo",
SeqNo: seqNo,
EventHash: eventHash,
PrevEventHash: prevEventHash,
}
}
func TestMemoryHeadStoreInOrderAdvances(t *testing.T) {
store := NewMemoryHeadStore()
for _, r := range []EventReceipt{receipt(1, "h1", ""), receipt(2, "h2", "h1"), receipt(3, "h3", "h2")} {
if err := checkAndAdvanceHead(store, r); err != nil {
t.Fatalf("unexpected: %v", err)
}
}
if seq, hash, ok := store.Get("str_demo"); !ok || seq != 3 || hash != "h3" {
t.Errorf("head = (%d,%s,%v)", seq, hash, ok)
}
}
func TestForgedRewoundReceiptIsFork(t *testing.T) {
store := NewMemoryHeadStore()
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
_ = checkAndAdvanceHead(store, receipt(2, "h2", "h1"))
err := checkAndAdvanceHead(store, receipt(2, "h2-fork", "h1"))
var fork *ForkDetectedError
if !errors.As(err, &fork) {
t.Fatalf("expected *ForkDetectedError, got %v", err)
}
if seq, hash, _ := store.Get("str_demo"); seq != 2 || hash != "h2" {
t.Errorf("store advanced past fork: (%d,%s)", seq, hash)
}
}
func TestDivergentNextEventIsFork(t *testing.T) {
store := NewMemoryHeadStore()
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
err := checkAndAdvanceHead(store, receipt(2, "h2", "WRONG"))
var fork *ForkDetectedError
if !errors.As(err, &fork) {
t.Fatalf("expected *ForkDetectedError, got %v", err)
}
}
func TestForwardGapAccepted(t *testing.T) {
store := NewMemoryHeadStore()
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
if err := checkAndAdvanceHead(store, receipt(5, "h5", "h4")); err != nil {
t.Fatalf("forward gap should be accepted: %v", err)
}
}
func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
path := filepath.Join(t.TempDir(), "heads.json")
store := NewFileHeadStore(path)
_ = checkAndAdvanceHead(store, receipt(1, "h1", ""))
_ = checkAndAdvanceHead(store, receipt(2, "h2", "h1"))
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %o, want 600", info.Mode().Perm())
}
reopened := NewFileHeadStore(path)
if seq, hash, ok := reopened.Get("str_demo"); !ok || seq != 2 || hash != "h2" {
t.Errorf("reopened head = (%d,%s,%v)", seq, hash, ok)
}
if err := checkAndAdvanceHead(reopened, receipt(2, "h2-fork", "h1")); err == nil {
t.Error("expected fork on reopened store")
}
}

80
iterator.go Normal file
View File

@@ -0,0 +1,80 @@
package attesto
import "context"
// PageFunc fetches one limit/offset page of list results.
type PageFunc func(ctx context.Context, limit, offset int) ([]M, error)
// Iterator walks limit/offset pages of a list endpoint transparently, stopping
// on the first short page. Same endpoints, no new API surface.
type Iterator struct {
fetch PageFunc
pageSize int
buffer []M
offset int
done bool
}
// NewIterator wraps any limit/offset PageFunc. pageSize <= 0 defaults to 100.
func NewIterator(fetch PageFunc, pageSize int) *Iterator {
if pageSize <= 0 {
pageSize = 100
}
return &Iterator{fetch: fetch, pageSize: pageSize}
}
// Next returns the next item, or (nil, nil) when the listing is exhausted.
func (it *Iterator) Next(ctx context.Context) (M, error) {
if len(it.buffer) == 0 && !it.done {
page, err := it.fetch(ctx, it.pageSize, it.offset)
if err != nil {
return nil, err
}
it.offset += it.pageSize
if len(page) < it.pageSize {
it.done = true
}
it.buffer = page
}
if len(it.buffer) == 0 {
return nil, nil
}
item := it.buffer[0]
it.buffer = it.buffer[1:]
return item, nil
}
// IterTenantStreamEvents returns an iterator over a stream's events.
func (c *Client) IterTenantStreamEvents(streamID string, pageSize int) *Iterator {
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
return c.ListTenantStreamEvents(ctx, streamID, limit, offset)
}, pageSize)
}
// IterTenantWindows returns an iterator over a stream's windows.
func (c *Client) IterTenantWindows(streamID string, pageSize int) *Iterator {
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
return c.ListTenantWindows(ctx, streamID, limit, offset)
}, pageSize)
}
// IterTenantCheckpoints returns an iterator over a stream's checkpoints.
func (c *Client) IterTenantCheckpoints(streamID string, pageSize int) *Iterator {
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
return c.ListTenantCheckpoints(ctx, streamID, limit, offset)
}, pageSize)
}
// IterForkEvidence returns an iterator over a stream's fork evidence.
func (c *Client) IterForkEvidence(streamID string, pageSize int) *Iterator {
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
return c.ListForkEvidence(ctx, streamID, limit, offset)
}, pageSize)
}
// IterTenantIVCEpochs returns an iterator over a stream's IVC epochs.
func (c *Client) IterTenantIVCEpochs(streamID string, pageSize int) *Iterator {
return NewIterator(func(ctx context.Context, limit, offset int) ([]M, error) {
return c.ListTenantIVCEpochs(ctx, streamID, limit, offset)
}, pageSize)
}

65
iterator_test.go Normal file
View File

@@ -0,0 +1,65 @@
package attesto
import (
"context"
"testing"
)
func TestIteratorDrainsThreePagesInOrder(t *testing.T) {
pages := map[int][]M{
0: {{"seqNo": 1}, {"seqNo": 2}, {"seqNo": 3}},
3: {{"seqNo": 4}, {"seqNo": 5}, {"seqNo": 6}},
6: {{"seqNo": 7}},
}
calls := 0
it := NewIterator(func(_ context.Context, limit, offset int) ([]M, error) {
calls++
if limit != 3 {
t.Fatalf("limit = %d, want 3", limit)
}
return pages[offset], nil
}, 3)
var seqs []int
for {
item, err := it.Next(context.Background())
if err != nil {
t.Fatal(err)
}
if item == nil {
break
}
seqs = append(seqs, item["seqNo"].(int))
}
want := []int{1, 2, 3, 4, 5, 6, 7}
if len(seqs) != len(want) {
t.Fatalf("seqs = %v", seqs)
}
for i, v := range want {
if seqs[i] != v {
t.Fatalf("seqs = %v, want %v", seqs, want)
}
}
if calls != 3 {
t.Errorf("calls = %d, want 3", calls)
}
}
func TestIteratorStopsCleanlyOnShortFirstPage(t *testing.T) {
calls := 0
it := NewIterator(func(_ context.Context, _, _ int) ([]M, error) {
calls++
return []M{{"streamId": "str_only"}}, nil
}, 100)
first, err := it.Next(context.Background())
if err != nil || first == nil {
t.Fatalf("first = %v err = %v", first, err)
}
second, err := it.Next(context.Background())
if err != nil || second != nil {
t.Fatalf("second = %v err = %v", second, err)
}
if calls != 1 {
t.Errorf("calls = %d, want 1 (short page must end iteration)", calls)
}
}

View File

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"math" "math"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -69,6 +70,421 @@ func SHA256Hex(value []byte) string {
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// maxSafeInteger mirrors JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1).
// Integers beyond this lose precision when a JS verifier parses them, so they
// would re-serialize to different canonical bytes across languages.
const maxSafeInteger = int64(1)<<53 - 1
// UnsafeNumberError reports a committed payload/metadata number whose
// canonical-JSON bytes diverge across Python, Go, and JavaScript (a non-integer,
// or an integer outside +/-(2^53 - 1)). Path is the JSON path to the offender.
type UnsafeNumberError struct {
Path string
Message string
}
func (e *UnsafeNumberError) Error() string { return e.Message }
func nonIntegerError(path string) *UnsafeNumberError {
return &UnsafeNumberError{
Path: path,
Message: fmt.Sprintf(
"non-integer numbers are not permitted in committed payloads (%s); "+
"encode decimals as strings", path),
}
}
func unsafeIntegerError(path string) *UnsafeNumberError {
return &UnsafeNumberError{
Path: path,
Message: fmt.Sprintf(
"integers beyond +/-2^53-1 are not permitted in committed payloads (%s); "+
"encode large integers as strings", path),
}
}
func assertSafeFloat(v float64, path string) error {
if math.IsInf(v, 0) || math.IsNaN(v) || math.Trunc(v) != v {
return nonIntegerError(path)
}
if math.Abs(v) > float64(maxSafeInteger) {
return unsafeIntegerError(path)
}
return nil
}
func assertSafeInt64(v int64, path string) error {
if v > maxSafeInteger || v < -maxSafeInteger {
return unsafeIntegerError(path)
}
return nil
}
func assertSafeNumberString(s, path string) error {
if strings.ContainsAny(s, ".eE") {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return nonIntegerError(path)
}
return assertSafeFloat(f, path)
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
// Outside int64 range is necessarily outside the safe integer range.
return unsafeIntegerError(path)
}
return assertSafeInt64(n, path)
}
// AssertCommitmentSafeNumbers rejects numbers whose canonical-JSON bytes diverge
// across Python, Go, and JavaScript, so a commitment computed here matches the
// backend's byte-for-byte. Mirrors assert_commitment_safe_numbers in the backend:
// non-integer numbers are rejected outright; integers must be within +/-(2^53 - 1).
// Booleans are exempt. Returns an *UnsafeNumberError on the first offending value.
func AssertCommitmentSafeNumbers(value any, path string) error {
switch v := value.(type) {
case nil, bool, string:
return nil
case json.Number:
return assertSafeNumberString(v.String(), path)
case float64:
return assertSafeFloat(v, path)
case float32:
return assertSafeFloat(float64(v), path)
case int:
return assertSafeInt64(int64(v), path)
case int8:
return assertSafeInt64(int64(v), path)
case int16:
return assertSafeInt64(int64(v), path)
case int32:
return assertSafeInt64(int64(v), path)
case int64:
return assertSafeInt64(v, path)
case uint:
return assertSafeUint64(uint64(v), path)
case uint8:
return assertSafeInt64(int64(v), path)
case uint16:
return assertSafeInt64(int64(v), path)
case uint32:
return assertSafeInt64(int64(v), path)
case uint64:
return assertSafeUint64(v, path)
case []any:
for i, item := range v {
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
return nil
case []M:
for i, item := range v {
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
return nil
case map[string]any:
return assertSafeMap(v, path)
case M:
return assertSafeMap(map[string]any(v), path)
default:
// Structs and other types: normalize through JSON exactly as the
// canonical encoder does, then re-check the plain representation.
raw, err := json.Marshal(v)
if err != nil {
return err
}
var decoded any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&decoded); err != nil {
return err
}
return AssertCommitmentSafeNumbers(decoded, path)
}
}
func assertSafeUint64(v uint64, path string) error {
if v > uint64(maxSafeInteger) {
return unsafeIntegerError(path)
}
return nil
}
func assertSafeMap(m map[string]any, path string) error {
for key, item := range m {
if err := AssertCommitmentSafeNumbers(item, fmt.Sprintf("%s.%s", path, key)); err != nil {
return err
}
}
return nil
}
// PayloadCommitment returns a deterministic commitment to an event payload,
// byte-identical to the server's stored payload_commitment. Call
// AssertCommitmentSafeNumbers first if the payload is not yet known to be safe.
func PayloadCommitment(payload any) (map[string]string, error) {
if err := EnsureSelfTest(); err != nil {
return nil, err
}
raw, err := CanonicalJSON(payload)
if err != nil {
return nil, err
}
return map[string]string{
"hash_alg": "sha256",
"canonical_payload_hash": SHA256Hex(raw),
}, nil
}
// MetadataCommitment returns a deterministic commitment to event metadata,
// byte-identical to the server's stored metadata_commitment.
func MetadataCommitment(metadata any) (map[string]string, error) {
raw, err := CanonicalJSON(metadata)
if err != nil {
return nil, err
}
return map[string]string{
"hash_alg": "sha256",
"canonical_metadata_hash": SHA256Hex(raw),
}, nil
}
func storedCommitmentHash(event map[string]any, commitmentKey, hashKey string) string {
containers := []any{event, event["envelope"], event["envelope_json"]}
for _, container := range containers {
obj, ok := container.(map[string]any)
if !ok {
continue
}
commitment, ok := obj[commitmentKey].(map[string]any)
if !ok {
continue
}
if stored, ok := commitment[hashKey].(string); ok {
return stored
}
}
return ""
}
// VerifyPayloadCommitment recomputes the payload commitment locally and compares
// it to the value stored on an event (payload_commitment.canonical_payload_hash,
// whether the event is given flat or under envelope / envelope_json). Returns
// false when the stored commitment is absent or differs.
func VerifyPayloadCommitment(payload any, event map[string]any) (bool, error) {
stored := storedCommitmentHash(event, "payload_commitment", "canonical_payload_hash")
if stored == "" {
return false, nil
}
commitment, err := PayloadCommitment(payload)
if err != nil {
return false, err
}
return commitment["canonical_payload_hash"] == stored, nil
}
// VerifyMetadataCommitment recomputes the metadata commitment locally and
// compares it to the value stored on an event.
func VerifyMetadataCommitment(metadata any, event map[string]any) (bool, error) {
stored := storedCommitmentHash(event, "metadata_commitment", "canonical_metadata_hash")
if stored == "" {
return false, nil
}
commitment, err := MetadataCommitment(metadata)
if err != nil {
return false, err
}
return commitment["canonical_metadata_hash"] == stored, nil
}
func windowNodeHash(left, right string) (string, error) {
return DomainHashHex(ProofstreamDomains["window"], map[string]any{
"kind": "node", "left_hash": left, "right_hash": right,
})
}
func checkpointNodeHash(left, right string) (string, error) {
return DomainHashHex(ProofstreamDomains["checkpoint"], map[string]any{
"kind": "node", "left_hash": left, "right_hash": right,
})
}
// InclusionStep is one node of a window inclusion proof.
type InclusionStep struct {
Side string `json:"side"`
Hash string `json:"hash"`
}
// VerifyInclusionProof folds a window inclusion proof from a leaf up to the
// window root. Mirrors verify_inclusion_proof in the backend windows.py: a left
// sibling hashes as node(sibling, current), a right sibling as node(current, sibling).
func VerifyInclusionProof(leafHash string, proof []InclusionStep, rootHash string) (bool, error) {
current := leafHash
for _, step := range proof {
var err error
switch step.Side {
case "left":
if step.Hash == "" {
return false, nil
}
current, err = windowNodeHash(step.Hash, current)
case "right":
if step.Hash == "" {
return false, nil
}
current, err = windowNodeHash(current, step.Hash)
default:
return false, nil
}
if err != nil {
return false, err
}
}
return current == rootHash, nil
}
// VerifyCheckpointRoot recomputes a checkpoint root from its window hashes and
// compares. Mirrors checkpoint_root_hash in the backend checkpoints.py: an odd
// node at any level is promoted unchanged (never duplicated/hashed with itself).
func VerifyCheckpointRoot(windowHashes []string, expectedRoot string) (bool, error) {
if len(windowHashes) == 0 {
return false, nil
}
level := append([]string{}, windowHashes...)
for len(level) > 1 {
next := make([]string, 0, (len(level)+1)/2)
for offset := 0; offset < len(level); offset += 2 {
if offset+1 >= len(level) {
next = append(next, level[offset]) // promote, do not duplicate
} else {
h, err := checkpointNodeHash(level[offset], level[offset+1])
if err != nil {
return false, err
}
next = append(next, h)
}
}
level = next
}
return level[0] == expectedRoot, nil
}
// VerifyCheckpointExtension checks that current continues previous: contiguous
// sequence (current.from_seq_no == previous.to_seq_no + 1) and back-link
// (current.previous_checkpoint_hash == previous.checkpoint_hash).
func VerifyCheckpointExtension(previous, current map[string]any) VerifyReport {
problems := make([]string, 0)
if asFloat(current["from_seq_no"]) != asFloat(previous["to_seq_no"])+1 {
problems = append(problems, "checkpoint does not extend previous (sequence gap)")
}
if !sameString(current["previous_checkpoint_hash"], previous["checkpoint_hash"]) {
problems = append(problems, "checkpoint previous_checkpoint_hash does not match previous")
}
return VerifyReport{Kind: "checkpoint-extension", OK: len(problems) == 0, Problems: problems}
}
// VerifyCompleteness proves no events were omitted in [fromSeqNo, toSeqNo]: the
// sequence numbers must be gap-free and every event's prev_event_hash must equal
// the previous event's event_hash (the per-stream hash chain).
func VerifyCompleteness(events []map[string]any, fromSeqNo, toSeqNo int) VerifyReport {
problems := make([]string, 0)
ordered := append([]map[string]any{}, events...)
sort.Slice(ordered, func(i, j int) bool {
return asFloat(ordered[i]["seq_no"]) < asFloat(ordered[j]["seq_no"])
})
gapFree := len(ordered) == toSeqNo-fromSeqNo+1
if gapFree {
for i, event := range ordered {
if int(asFloat(event["seq_no"])) != fromSeqNo+i {
gapFree = false
break
}
}
}
if !gapFree {
problems = append(problems, "sequence range is not gap-free")
} else {
for i := 1; i < len(ordered); i++ {
if !sameString(ordered[i]["prev_event_hash"], ordered[i-1]["event_hash"]) {
problems = append(problems, fmt.Sprintf(
"event chain broken at seq_no %d", int(asFloat(ordered[i]["seq_no"]))))
break
}
}
}
return VerifyReport{Kind: "completeness", OK: len(problems) == 0, Problems: problems}
}
func asFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case json.Number:
f, _ := n.Float64()
return f
}
return 0
}
func sameString(a, b any) bool {
as, aok := a.(string)
bs, bok := b.(string)
if aok && bok {
return as == bs
}
return a == nil && b == nil
}
func webhookHeader(headers map[string]string, name string) (string, bool) {
for key, value := range headers {
if strings.EqualFold(key, name) {
return value, true
}
}
return "", false
}
// VerifyWebhook verifies an inbound webhook delivered by Attesto. It reads the
// X-Attesto-Timestamp / X-Attesto-Signature headers (case-insensitive), rejects
// when the timestamp is outside maxSkewS of now, recomputes
// hmac_sha256(secret, "<ts>." + body), and compares in constant time. Mirrors
// the backend webhook signing scheme.
func VerifyWebhook(body []byte, headers map[string]string, secret string, maxSkewS int) bool {
return verifyWebhookAt(body, headers, secret, maxSkewS, time.Now().Unix())
}
func verifyWebhookAt(body []byte, headers map[string]string, secret string, maxSkewS int, now int64) bool {
timestamp, okTS := webhookHeader(headers, "x-attesto-timestamp")
signature, okSig := webhookHeader(headers, "x-attesto-signature")
if !okTS || !okSig {
return false
}
ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
if err != nil {
return false
}
skew := now - ts
if skew < 0 {
skew = -skew
}
if skew > int64(maxSkewS) {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10)))
mac.Write([]byte("."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) { func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
if timestamp == 0 { if timestamp == 0 {
timestamp = time.Now().Unix() timestamp = time.Now().Unix()
@@ -90,6 +506,9 @@ func SignedConnectorWebhookHeaders(secret string, body []byte, timestamp int64)
} }
func VerifyReceiptOffline(receipt SignedReceipt, publicKeyHex string) VerifyReport { func VerifyReceiptOffline(receipt SignedReceipt, publicKeyHex string) VerifyReport {
if err := EnsureSelfTest(); err != nil {
return VerifyReport{Kind: VerifyReceipt, OK: false, Problems: []string{err.Error()}}
}
problems := make([]string, 0) problems := make([]string, 0)
hash, err := DomainHashHex(ProofstreamDomains["receipt"], receipt.Payload) hash, err := DomainHashHex(ProofstreamDomains["receipt"], receipt.Payload)
if err != nil { if err != nil {

203
proofstream_parity_test.go Normal file
View File

@@ -0,0 +1,203 @@
package attesto
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
type parityVectors struct {
Accept []struct {
ID string `json:"id"`
Payload map[string]any `json:"payload"`
CanonicalPayloadHash string `json:"canonical_payload_hash"`
} `json:"accept"`
Reject []struct {
ID string `json:"id"`
Payload map[string]any `json:"payload"`
Path string `json:"path"`
} `json:"reject"`
}
func loadParityVectors(t *testing.T) parityVectors {
t.Helper()
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "canonical-numbers.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read parity vectors: %v", err)
}
var v parityVectors
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode parity vectors: %v", err)
}
return v
}
func TestParityAcceptCommitmentHashes(t *testing.T) {
for _, c := range loadParityVectors(t).Accept {
commitment, err := PayloadCommitment(c.Payload)
if err != nil {
t.Fatalf("%s: PayloadCommitment: %v", c.ID, err)
}
if commitment["hash_alg"] != "sha256" {
t.Errorf("%s: hash_alg %q", c.ID, commitment["hash_alg"])
}
if commitment["canonical_payload_hash"] != c.CanonicalPayloadHash {
t.Errorf("%s: hash %s != %s", c.ID, commitment["canonical_payload_hash"], c.CanonicalPayloadHash)
}
}
}
func TestParityAcceptPassPreflight(t *testing.T) {
for _, c := range loadParityVectors(t).Accept {
if err := AssertCommitmentSafeNumbers(c.Payload, "$"); err != nil {
t.Errorf("%s: unexpected preflight error: %v", c.ID, err)
}
}
}
func TestParityRejectPaths(t *testing.T) {
for _, c := range loadParityVectors(t).Reject {
err := AssertCommitmentSafeNumbers(c.Payload, "$")
var ue *UnsafeNumberError
if !errors.As(err, &ue) {
t.Errorf("%s: expected *UnsafeNumberError, got %v", c.ID, err)
continue
}
if ue.Path != c.Path {
t.Errorf("%s: path %s != %s", c.ID, ue.Path, c.Path)
}
}
}
type receiptParityVectors struct {
Cases []struct {
ID string `json:"id"`
PublicKeyHex string `json:"public_key_hex"`
Receipt SignedReceipt `json:"receipt"`
ExpectOK bool `json:"expect_ok"`
ExpectProblem []string `json:"expect_problems"`
} `json:"cases"`
}
func TestParityReceiptVerification(t *testing.T) {
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipts.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read receipt vectors: %v", err)
}
var v receiptParityVectors
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode receipt vectors: %v", err)
}
for _, c := range v.Cases {
report := VerifyReceiptOffline(c.Receipt, c.PublicKeyHex)
if report.OK != c.ExpectOK {
t.Errorf("%s: ok=%v want %v (problems=%v)", c.ID, report.OK, c.ExpectOK, report.Problems)
}
if len(report.Problems) != len(c.ExpectProblem) {
t.Errorf("%s: problems=%v want %v", c.ID, report.Problems, c.ExpectProblem)
continue
}
for i, p := range c.ExpectProblem {
if report.Problems[i] != p {
t.Errorf("%s: problem[%d]=%q want %q", c.ID, i, report.Problems[i], p)
}
}
}
}
type inclusionParityVectors struct {
Inclusion []struct {
ID string `json:"id"`
LeafHash string `json:"leaf_hash"`
Proof []InclusionStep `json:"proof"`
RootHash string `json:"root_hash"`
ExpectOK bool `json:"expect_ok"`
} `json:"inclusion"`
CheckpointRoot []struct {
ID string `json:"id"`
WindowHashes []string `json:"window_hashes"`
ExpectedRoot string `json:"expected_root"`
ExpectOK bool `json:"expect_ok"`
} `json:"checkpoint_root"`
CheckpointExtension []struct {
ID string `json:"id"`
Previous map[string]any `json:"previous"`
Current map[string]any `json:"current"`
ExpectOK bool `json:"expect_ok"`
} `json:"checkpoint_extension"`
Completeness []struct {
ID string `json:"id"`
Events []map[string]any `json:"events"`
FromSeqNo int `json:"from_seq_no"`
ToSeqNo int `json:"to_seq_no"`
ExpectOK bool `json:"expect_ok"`
} `json:"completeness"`
}
func TestParityInclusionAndCheckpoint(t *testing.T) {
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "inclusion.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read inclusion vectors: %v", err)
}
var v inclusionParityVectors
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode inclusion vectors: %v", err)
}
for _, c := range v.Inclusion {
ok, err := VerifyInclusionProof(c.LeafHash, c.Proof, c.RootHash)
if err != nil {
t.Fatalf("%s: %v", c.ID, err)
}
if ok != c.ExpectOK {
t.Errorf("inclusion %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
}
}
for _, c := range v.CheckpointRoot {
ok, err := VerifyCheckpointRoot(c.WindowHashes, c.ExpectedRoot)
if err != nil {
t.Fatalf("%s: %v", c.ID, err)
}
if ok != c.ExpectOK {
t.Errorf("checkpoint_root %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
}
}
for _, c := range v.CheckpointExtension {
report := VerifyCheckpointExtension(c.Previous, c.Current)
if report.OK != c.ExpectOK {
t.Errorf("extension %s: ok=%v want %v (%v)", c.ID, report.OK, c.ExpectOK, report.Problems)
}
}
for _, c := range v.Completeness {
report := VerifyCompleteness(c.Events, c.FromSeqNo, c.ToSeqNo)
if report.OK != c.ExpectOK {
t.Errorf("completeness %s: ok=%v want %v (%v)", c.ID, report.OK, c.ExpectOK, report.Problems)
}
}
}
func TestParityVerifyPayloadCommitment(t *testing.T) {
for _, c := range loadParityVectors(t).Accept {
commitment, err := PayloadCommitment(c.Payload)
if err != nil {
t.Fatalf("%s: PayloadCommitment: %v", c.ID, err)
}
stored := map[string]any{}
for k, v := range commitment {
stored[k] = v
}
event := map[string]any{"envelope": map[string]any{"payload_commitment": stored}}
ok, err := VerifyPayloadCommitment(c.Payload, event)
if err != nil || !ok {
t.Errorf("%s: verify match ok=%v err=%v", c.ID, ok, err)
}
bad, err := VerifyPayloadCommitment(map[string]any{"tampered": float64(1)}, event)
if err != nil || bad {
t.Errorf("%s: tampered verify bad=%v err=%v", c.ID, bad, err)
}
}
}

72
selftest.go Normal file
View File

@@ -0,0 +1,72 @@
package attesto
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"sync"
)
// [P1.10] Trimmed parity vectors vendored into the package; regenerated from
// golden-vectors/sdk-parity (do not hand-edit).
//
//go:embed selftest_vectors.json
var selftestVectors []byte
// ErrSelfTest wraps a failed vendored parity self-test: this install's hashing
// diverges from the pinned cross-language vectors (corrupted package or broken
// runtime). The SDK fails closed rather than produce wrong evidence.
var ErrSelfTest = errors.New("attesto self-test failed")
var (
selftestOnce sync.Once
selftestErr error
)
func runSelfTest(raw []byte) error {
var vectors struct {
Commitment struct {
Payload map[string]any `json:"payload"`
CanonicalPayloadHash string `json:"canonical_payload_hash"`
} `json:"commitment"`
Receipt struct {
Payload map[string]any `json:"payload"`
ReceiptHash string `json:"receipt_hash"`
} `json:"receipt"`
Inclusion struct {
LeafHash string `json:"leaf_hash"`
Proof []InclusionStep `json:"proof"`
RootHash string `json:"root_hash"`
} `json:"inclusion"`
}
if err := json.Unmarshal(raw, &vectors); err != nil {
return fmt.Errorf("%w: vendored vectors unreadable: %v", ErrSelfTest, err)
}
canonical, err := CanonicalJSON(vectors.Commitment.Payload)
if err != nil {
return fmt.Errorf("%w: %v", ErrSelfTest, err)
}
if SHA256Hex(canonical) != vectors.Commitment.CanonicalPayloadHash {
return fmt.Errorf("%w: commitment hash diverged from vendored vector", ErrSelfTest)
}
receiptHash, err := DomainHashHex(ProofstreamDomains["receipt"], vectors.Receipt.Payload)
if err != nil {
return fmt.Errorf("%w: %v", ErrSelfTest, err)
}
if receiptHash != vectors.Receipt.ReceiptHash {
return fmt.Errorf("%w: receipt domain-hash diverged from vendored vector", ErrSelfTest)
}
ok, err := VerifyInclusionProof(
vectors.Inclusion.LeafHash, vectors.Inclusion.Proof, vectors.Inclusion.RootHash)
if err != nil || !ok {
return fmt.Errorf("%w: inclusion fold diverged from vendored vector", ErrSelfTest)
}
return nil
}
// EnsureSelfTest runs the vendored parity self-test once per process (cached).
func EnsureSelfTest() error {
selftestOnce.Do(func() { selftestErr = runSelfTest(selftestVectors) })
return selftestErr
}

26
selftest_test.go Normal file
View File

@@ -0,0 +1,26 @@
package attesto
import (
"bytes"
"errors"
"testing"
)
func TestSelfTestPassesOnVendoredVectors(t *testing.T) {
if err := EnsureSelfTest(); err != nil {
t.Fatalf("self-test failed on shipped vectors: %v", err)
}
}
func TestCorruptedVendoredVectorFailsClosed(t *testing.T) {
corrupted := bytes.Replace(
selftestVectors,
[]byte(`"canonical_payload_hash": "`),
[]byte(`"canonical_payload_hash": "0`),
1,
)
err := runSelfTest(corrupted)
if !errors.Is(err, ErrSelfTest) {
t.Fatalf("expected ErrSelfTest, got %v", err)
}
}

58
selftest_vectors.json Normal file
View File

@@ -0,0 +1,58 @@
{
"note": "Trimmed parity self-test set vendored into each SDK package; regenerated from golden-vectors/sdk-parity (do not hand-edit).",
"commitment": {
"payload": {
"b": 2,
"a": 1,
"nested": {
"z": [
1,
2,
3
],
"flag": true
}
},
"canonical_payload_hash": "8b2252e6632801faa71c8eb4a597bfeaba385face3ddef30b7eb636787902304"
},
"receipt": {
"payload": {
"event_hash": "cd7753e1bc862729d4de6d2e9d9c0942830e557cf539ebcbf00bf9b4cd6a62a9",
"event_id": "evt_golden_2",
"issued_at": "2026-06-05T20:59:04.000Z",
"prev_event_hash": "731f8a0a3cc6ea2ca00bb26dca8f721da34e1328750f2e16a32f7bf7b36d7264",
"protocol": "ATTESTO-PROOFSTREAM-001",
"protocol_version": "0.1-alpha",
"seq_no": 2,
"signer": {
"alg": "ed25519",
"key_epoch": "golden-key-2026-06",
"kid": "golden-key-2026-06"
},
"stream_event_id": "sev_golden_2",
"stream_head_hash": "140142e46eb7cbe17db99c717666cb5a038a858feaff7cc26b78d10c8868da51",
"stream_id": "str_golden",
"system_id": "sys_golden",
"tenant_id": "tnt_golden"
},
"receipt_hash": "4f72f15fa4d07c9a83baa908f6168e27beeb1d4c9984f70e52f67ff6c380ccb2"
},
"inclusion": {
"leaf_hash": "7973a148f766058042177a3507c5baa4757c6159c91049cbc42f60f138715072",
"proof": [
{
"side": "right",
"hash": "2faac607f33011ec3bdf305e01e36d1257bb226868cec5df4bfecbb8a552b48b"
},
{
"side": "right",
"hash": "9ff585ea4fd08983d242fd3b73144e800b07a4ae86324d4e0f0fb2f70933b26f"
},
{
"side": "right",
"hash": "6977f0ace2809f289002e6600967de240acc3dcdcf62aa4e078c56c10f57d381"
}
],
"root_hash": "37a6a0d69e0951df3827205cfb8440c84d5d60c729b4c00a0d9460361923a18b"
}
}

172
testdata/APSProvenance.abi.json vendored Normal file
View File

@@ -0,0 +1,172 @@
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [
{
"internalType": "string",
"name": "batchId",
"type": "string"
}
],
"name": "AlreadyCommitted",
"type": "error"
},
{
"inputs": [],
"name": "EmptyBatchId",
"type": "error"
},
{
"inputs": [],
"name": "NotOwner",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "string",
"name": "batchId",
"type": "string"
},
{
"indexed": true,
"internalType": "bytes32",
"name": "merkleRoot",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "uint32",
"name": "runCount",
"type": "uint32"
},
{
"indexed": false,
"internalType": "string",
"name": "model",
"type": "string"
},
{
"indexed": false,
"internalType": "uint64",
"name": "blockTimestamp",
"type": "uint64"
}
],
"name": "RootCommitted",
"type": "event"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "batchIds",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "batchId",
"type": "string"
},
{
"internalType": "bytes32",
"name": "merkleRoot",
"type": "bytes32"
},
{
"internalType": "uint32",
"name": "runCount",
"type": "uint32"
},
{
"internalType": "string",
"name": "model",
"type": "string"
}
],
"name": "commitRoot",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getBatchCount",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "batchId",
"type": "string"
}
],
"name": "getCommitment",
"outputs": [
{
"internalType": "bytes32",
"name": "merkleRoot",
"type": "bytes32"
},
{
"internalType": "uint32",
"name": "runCount",
"type": "uint32"
},
{
"internalType": "string",
"name": "model",
"type": "string"
},
{
"internalType": "uint64",
"name": "blockTimestamp",
"type": "uint64"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]

View File

@@ -6,6 +6,10 @@ type M map[string]any
type RequestOptions struct { type RequestOptions struct {
IdempotencyKey string IdempotencyKey string
// SkipPreflight disables the client-side commitment number preflight, which
// rejects non-integer / out-of-range numbers locally before LogEvent /
// LogEvents sends them. Default false: the preflight runs.
SkipPreflight bool
} }
type StreamCreateInput struct { type StreamCreateInput struct {

View File

@@ -1,8 +1,11 @@
package attesto package attesto
const ( const (
SDKVersion = "0.2.0" SDKVersion = "0.3.0"
DefaultBaseURL = "https://verify.attesto.eu" DefaultBaseURL = "https://verify.attesto.eu"
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001" ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
ProtocolVersionAlpha = "0.1-alpha" ProtocolVersionAlpha = "0.1-alpha"
// ProtocolHandshake is sent as X-Attesto-Protocol on every request; the
// backend answers 426 when it speaks a different protocol generation.
ProtocolHandshake = ProofstreamProtocol + "/" + ProtocolVersionAlpha
) )

65
webhook_parity_test.go Normal file
View File

@@ -0,0 +1,65 @@
package attesto
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
type webhookParityVectors struct {
Cases []struct {
ID string `json:"id"`
Secret string `json:"secret"`
Body string `json:"body"`
Headers map[string]string `json:"headers"`
Now int64 `json:"now"`
MaxSkewS int `json:"max_skew_s"`
ExpectOK bool `json:"expect_ok"`
} `json:"cases"`
}
func TestParityWebhookVerification(t *testing.T) {
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "webhook.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read webhook vectors: %v", err)
}
var v webhookParityVectors
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode webhook vectors: %v", err)
}
for _, c := range v.Cases {
ok := verifyWebhookAt([]byte(c.Body), c.Headers, c.Secret, c.MaxSkewS, c.Now)
if ok != c.ExpectOK {
t.Errorf("webhook %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
}
}
}
func TestWebhookHeaderLookupIsCaseInsensitive(t *testing.T) {
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "webhook.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read webhook vectors: %v", err)
}
var v webhookParityVectors
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode webhook vectors: %v", err)
}
for _, c := range v.Cases {
if c.ID != "valid" {
continue
}
lowered := map[string]string{}
for key, value := range c.Headers {
lowered[key] = value
}
// Re-key with different casing.
lowered["X-ATTESTO-TIMESTAMP"] = lowered["X-Attesto-Timestamp"]
delete(lowered, "X-Attesto-Timestamp")
if !verifyWebhookAt([]byte(c.Body), lowered, c.Secret, c.MaxSkewS, c.Now) {
t.Error("case-insensitive header lookup failed")
}
}
}