Compare commits

...

20 Commits

Author SHA1 Message Date
Codex
315f7f05f2 release: SDK 0.4.0, local-vault 0.2.0, n8n 0.2.0 — version bumps
Brings the published packages level with the code shipped since the last
publish (offline-verify exports, attestedFetch, OTel bridges, portable
receipts, head-tracking fix, etc. for the SDK; init+doctor for local-vault).
Versions move in lockstep as the publication-parity contract requires:
PyPI/npm/Go SDK/CLI all 0.4.0. n8n node bumps to 0.2.0 with its @attesto/sdk
dep widened to ^0.4.0. attesto-mcp stays 0.1.0 (first publish).

No package contains source maps or non-runtime source: npm ships compiled
.js + .d.ts only (zero .ts, zero .map, verified), Python wheels ship runtime
.py only (no sdist, no tests), and no wheel/tarball contains anything from
backend/, gateway/, or the Rust prover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:22:41 +02:00
Codex
b5cece2822 gates: cargo fmt + secret-scan-clean test fixtures
nova_e2e_contract's cargo fmt --check now passes (formatting from the
Plan B circuit work), and the two scanner-flagged test fixtures use
allowlisted fake-markers: the gateway test provider key carries "dummy"
and the Go emulator API key is atto_test_abc123... (valid 32-hex,
"abc123" marker). Gateway + Go suites green; secret scan 0 findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:12:54 +02:00
Codex
53ae31e196 feat(D.5): attesto connector init — marketplace-ready scaffold
`attesto connector init <slug> [--name --category --dir]` generates
attesto.connector.json (v2 manifest), webhook_handler.py wired to the
P1.4 verify_webhook helper with its real signature, and a README with the
submission flow; `--validate-only <dir>` re-runs the marketplace
validator (the same connectorkit.ValidateManifest code) as a local
pre-submission check.

Honesty rule: a fresh scaffold cannot claim a green assurance canary, so
runtime.canary ships as "pending" and the validator's single remaining
finding IS the submission to-do list; the scaffold errors if its template
ever drifts into any other finding. Verified end-to-end: generated stub
accepts a genuinely signed webhook and rejects a forged signature against
the published Python SDK; overwrite refusal tested; Go suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:34:47 +02:00
Codex
8dd6c4a784 feat(P3.3): OTel bridge + idempotency fidelity fixes it surfaced
AttestoSpanProcessor (attesto.otel / @attesto/sdk) turns ended OTel spans
into commitment events: source_ref otel:{trace_id}:{span_id} so resending
a span is idempotent, only allowlisted attributes committed (as a
commitment, never raw — non-allowlisted values provably absent from
stored objects), fail-open with onError, strict opt-in. Both
implementations are structurally compatible with the SpanProcessor
interface, so neither SDK gains an opentelemetry dependency.

Building this surfaced two real gaps, fixed in all three languages:
- Emulators now deduplicate on (source_kind, source_ref) like real
  ingestion (resend returns the existing receipt; anonymous empty refs
  exempt) — previously a resend silently appended a duplicate event.
- P1.6 head tracking treated an exact idempotent replay (same seq_no AND
  same event_hash as the stored head) as a fork; it is now a benign no-op,
  while same-seq/different-hash remains AttestoForkDetected (regression
  tests in Python, TypeScript-path via emulator test, and Go).

Suites: Python 109 passed, TypeScript 77 passed, Go 4/4 packages ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:14:45 +02:00
Codex
6858bbcdd8 feat(P3.4): portable receipts, attestedFetch, edge-runtime lane, receipt PDF
Portable receipt export (*.attesto.json): export_receipt_file /
verify_receipt_file in Python, exportReceiptFile / verifyReceiptFile in
TypeScript, ExportReceiptFile / VerifyReceiptExport in Go, plus
`attesto verify file` in the CLI. New normative corpus
golden-vectors/sdk-parity/receipt-export.json (valid, tampered-inner,
linkage-mismatch, wrong-format, embedded-hint-only) passes identically in
all three SDKs; a Python-made export verifies through the Go CLI
end-to-end. Embedded witness keys are explicit second-class hints
(kind=receipt-export-selfcontained).

attestedFetch (TS) attests AI calls at the transport exactly like the
gateway: OpenAI-compatible paths -> attesto.model_decision with
commitments only (SSE reassembled after byte-for-byte pass-through),
anything else -> http_call; fail-open by default with onError, strict
rejects; attest() wraps any function with a commitment event +
lastReceipt. 5 emulator tests prove raw prompt/completion text never
appears in any stored object.

Edge runtimes: new guard test fails the build if any node: builtin enters
the dist/index.js module graph (FileHeadStore stays out by design), and
the receipt+export corpora now run on Bun in CI (10 cases green locally).

render_receipt_pdf ships behind the attesto[receipt-pdf] extra (fpdf2 +
qrcode, pure Python; core stays light) — one-page rendering with a QR of
{receipt_hash, event_hash} and a disclaimer that the JSON, not the PDF,
is the evidence; clean ImportError naming the extra when absent.

Also fixed a stale CI assertion: the npm package-install smoke pinned
SDK_VERSION 0.1.1; it now reads the version from package.json.

Suites: Python 106 passed, TypeScript 67+5 passed, Go green, package
policy contract green. Connectorkit already exists in all three languages
(no port needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:57:34 +02:00
Codex
4a2d8645b0 feat(P3.1): WASM verifier + zero-network /verify drop-zone
sdk/go/cmd/attesto-verify-wasm compiles the offline verification functions
(receipt, inclusion, checkpoint root, completeness) — and nothing else —
to WebAssembly, exported on a global attestoVerify object.
scripts/build_wasm_verifier.sh prefers TinyGo and falls back to Go stdlib
(current build: stdlib, 5.9 MB; the <4 MB target applies when TinyGo is in
the toolchain). docs-site /verify is a drag-drop page that verifies
receipts entirely in the browser against a user-pinned witness key.

Verified, both wired into CI as a new wasm-verifier job:
- scripts/wasm_verifier_smoke.mjs loads the wasm in Node with no network
  and reproduces all 19 sdk-parity corpus cases (receipts + inclusion +
  checkpoint-root + completeness) — the same corpus gating the three SDKs;
- the smoke also asserts the /verify page is zero-network: its only fetch
  is the same-origin wasm asset and no script references an absolute URL.

wasm + page hashed into the release manifest; docs-hub contract green
(shared chrome + content rules).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:25:43 +02:00
Codex
2276f4da09 docs(P3.5+P4.1+W.1): canonical JSON spec, countersignature ADR, witness ADR
ATTESTO-CANONICAL-JSON-001 freezes the byte-level rules every hash and
signature depends on (normalization table, no-whitespace serialization,
FIX-11 number policy, domain framing) and names golden-vectors/sdk-parity/
as the normative conformance corpus with a 6-step checklist for new
implementations; hashed into the release manifest and linked from all
three SDK READMEs + the crypto review checklist.

ADR-0006 (client countersignatures) specifies the full P4.1 scheme —
signed bytes under attesto.v2.client-event over commitments, kid registry
with rotation-safe resolution at occurred_at, replay analysis, binding
claim wording — status proposed; no code until approved (P4 rule).

ADR-0009 (independent witness network) records the W.1 design: verbatim
purpose line, privacy-preserving framing rule, hashes-only observation,
opt-in pseudonymous stream digests, the four CI-enforced separation rules
(zero SDK coupling, never a transitive dep, never auto-enroll, never
background on install), backend surface spec, v1 observational-only scope,
and the claims-guarded evolution note kept ADR-internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:59:23 +02:00
Codex
217db5a11e release(P2.4 — Gitea-CI variant): cosign-signed releases, fail-closed
Keyless OIDC signing is unavailable off GitHub, so releases are signed with a
managed cosign key: the private half lives only in the operator keystore and
the CI secret (COSIGN_KEY); the public half is pinned in-repo at
ops/release-signing/cosign.pub and served at https://get.attesto.eu/cosign.pub.

scripts/sign_release_artifacts.sh signs dist/cli/SHA256SUMS (classic detached
signature; cosign v3 flags pinned), verifies its own output against the
in-repo public anchor before declaring success, and normalizes the signature
to world-readable. The CI cli-release-binaries job now signs on every v* tag
and FAILS CLOSED when the secret is missing — no unsigned release can ship.

The live 0.3.0 release on get.attesto.eu is signed and the full public
auditor path is verified end-to-end: download SHA256SUMS + .sig + cosign.pub
from get.attesto.eu, cosign verify-blob -> Verified OK. "Verify this SDK
before you trust its verifier" commands added to the Go README and to the
Due-Diligence publication evidence (contract green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:05:22 +02:00
Codex
ce9b8ccfbb sdk(P2.2): typed compliance events + attest/session + Article 12 report
Typed events as SDK-side conventions (no backend change): ModelDecision /
HumanOverride / IncidentReport (NIS2 field names) / DataAccess as Python
dataclasses, TypeScript builders, and Go structs — each serializing to a plain
payload with regulation_refs (EU AI Act Art.12/14, NIS2 Art.23, AI-Act Art.62,
GDPR Art.30/6) and self-validating against the committed-payload number policy.

Python ergonomics: @attest(client, stream_id=...) wraps any function — one
event per call with commitments over args/kwargs and result (raw values never
leave the process), .last_receipt on the wrapper, exceptions log an
IncidentReport-shaped event (commitment over the traceback) and re-raise;
logging failures never break the workload (log-and-continue; strict=True is
the only raising mode — all test-enforced). session(...) groups typed events
under shared session_id/actor_ref metadata.

Evidence report: attesto.reports.article12(...) in Python and
`attesto report article12 --stream ... --output report.md` in the Go CLI —
deterministic templating (never LLM-generated) built only from existing tenant
endpoints: Art.12(2) coverage table, per-type event counts, P1.3 completeness
verdict, checkpoint -> anchor-tx -> block path, and replayable verification
commands. Claims discipline test-enforced in both languages: the words
"compliant"/"compliance guaranteed" never appear — the report states evidence
recorded and independently verifiable. The mock emulators now expose
event_type in tenant listings so report tests run end-to-end against P2.3.

Sweep green: Python 94, TS 59, Go all packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:23:13 +02:00
Codex
227ea57bd5 sdk(P2.3): MockAttesto — local emulator in all three SDKs
Customers can now test their full ingest-and-verify pipeline in CI with zero
network and zero Attesto account. Python attesto.testing.MockAttesto (context
manager over a local HTTP server + pytest-fixture friendly), TypeScript
createMockServer() (fetch-compatible handler, WebCrypto Ed25519, edge-safe),
and Go attestotest.NewServer() (httptest) implement the v2 subset the SDKs
use — streams, single+batch events, head, receipts, tenant event listings —
with REAL seq/hash-chain semantics via the same frozen canonical functions,
the server-side number-policy mirror (422), and windows/checkpoints built on
demand with per-leaf inclusion proofs (promote-odd-node fold).

Hard rule, test-enforced in all three languages: mock evidence is structurally
incapable of passing as real — every emitted object carries "mock": true,
receipts are signed by a per-instance throwaway key under kid
attesto-mock-ed25519, and verify_receipt against any real witness key fails.
Acceptance: the P1 verify suite (receipt, payload commitment, inclusion,
completeness) passes against the emulator with real clients in all three
SDKs; head tracking sees an honestly chained sequence. READMEs gain a
"Testing without Attesto" quickstart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:12:33 +02:00
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
30 changed files with 3727 additions and 13 deletions

167
README.md
View File

@@ -7,7 +7,17 @@ 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
```
CLI binaries: `curl -fsSL https://get.attesto.eu | sh` (checksum-verified).
Verify the release signature before you trust its verifier:
```shell
curl -fsSO https://get.attesto.eu/cosign.pub
curl -fsSO https://get.attesto.eu/0.3.0/SHA256SUMS
curl -fsSO https://get.attesto.eu/0.3.0/SHA256SUMS.sig
cosign verify-blob --key cosign.pub --insecure-ignore-tlog --signature SHA256SUMS.sig SHA256SUMS
``` ```
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 +35,7 @@ import (
"os" "os"
"time" "time"
attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" attesto "go.attesto.eu/sdk"
) )
func main() { func main() {
@@ -64,6 +74,9 @@ Attesto stores source-system time separately from backend ingest time.
`time.Now().UTC()` when omitted, but production integrations should pass the `time.Now().UTC()` when omitted, but production integrations should pass the
real upstream event timestamp whenever the source system provides one. real upstream event timestamp whenever the source system provides one.
Canonicalization is specified normatively in [ATTESTO-CANONICAL-JSON-001](../../docs/protocol/ATTESTO-CANONICAL-JSON-001.md); the parity corpus `golden-vectors/sdk-parity/` is its conformance set.
## Committed payload number rule ## Committed payload number rule
When events are committed to a Proofstream, payload and metadata numbers must When events are committed to a Proofstream, payload and metadata numbers must
@@ -72,6 +85,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 +112,142 @@ 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))
```
## Typed compliance events and the evidence report
```go
decision := attesto.ModelDecision{Model: "credit-v1", Decision: "approve", ConfidenceBp: 8700}
payload, _ := decision.ToPayload() // regulation_refs attached, number-policy validated
client.LogEvent(ctx, streamID, attesto.EventInput{
SourceRef: "d-1", EventType: decision.EventType(), Payload: payload,
})
```
```bash
attesto report article12 --stream str_... --output report.md
```
The report is a deterministic template (never LLM-generated) stating what is
recorded and independently verifiable — it never asserts conformity.
## Testing without Attesto: attestotest
`go.attesto.eu/sdk/attestotest` starts a local httptest emulator with **real**
hash-chain semantics; point the real client at it and run your full pipeline
in CI with zero network:
```go
server := attestotest.NewServer()
defer server.Close()
client, _ := attesto.NewClient(server.APIKey, attesto.WithBaseURL(server.URL))
stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
receipt, _ := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{SourceRef: "e1"})
stored, _ := client.GetReceipt(ctx, receipt.StreamEventID)
report := attesto.VerifyReceiptOffline(stored.Receipt, server.PublicKeyHex)
```
Mock evidence can never pass as real: every object carries `mock: true`, the
signer kid is `attesto-mock-ed25519`, and verification against any real
witness key fails.
## 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
}

428
attestotest/server.go Normal file
View File

@@ -0,0 +1,428 @@
// Package attestotest provides a local, in-memory Attesto v2 emulator for
// tests ([P2.3]). NewServer starts an httptest.Server implementing the v2
// subset the SDK uses, with REAL seq/hash-chain semantics via the same frozen
// canonical functions and receipts signed by a per-instance throwaway Ed25519
// key under kid "attesto-mock-ed25519". Every emitted object carries
// mock: true, so mock evidence is structurally incapable of passing as real.
package attestotest
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"sync"
"time"
attesto "go.attesto.eu/sdk"
)
// MockKid is the signer kid on every mock receipt.
const MockKid = "attesto-mock-ed25519"
type mockEvent struct {
Envelope attesto.M
EventHash string
StreamHeadHash string
StreamEventID string
TenantView attesto.M
}
// Server is the running emulator. Point a real client at URL; verify its
// receipts offline with PublicKeyHex.
type Server struct {
URL string
APIKey string
PublicKeyHex string
httpServer *httptest.Server
priv ed25519.PrivateKey
mu sync.Mutex
streams map[string]attesto.M
events map[string][]*mockEvent
receipts map[string]attesto.M
counter int
}
// Close shuts the emulator down.
func (s *Server) Close() { s.httpServer.Close() }
func (s *Server) id(prefix string) string {
s.counter++
return fmt.Sprintf("%s_mock%08d", prefix, s.counter)
}
func nowISO() string {
return time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
}
func safeNumbers(value any) bool {
switch v := value.(type) {
case json.Number:
if _, err := v.Int64(); err != nil {
return false
}
n, _ := v.Int64()
return n <= 1<<53-1 && n >= -(1<<53-1)
case float64:
return v == float64(int64(v)) && v <= float64(int64(1)<<53-1) && v >= -float64(int64(1)<<53-1)
case map[string]any:
for _, item := range v {
if !safeNumbers(item) {
return false
}
}
case []any:
for _, item := range v {
if !safeNumbers(item) {
return false
}
}
}
return true
}
func mustHash(domain string, value any) string {
h, err := attesto.DomainHashHex(domain, value)
if err != nil {
panic(err)
}
return h
}
// NewServer starts the emulator.
func NewServer() *Server {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
s := &Server{
APIKey: "atto_test_abc12300000000000000000000000000",
PublicKeyHex: hex.EncodeToString(pub),
priv: priv,
streams: map[string]attesto.M{},
events: map[string][]*mockEvent{},
receipts: map[string]attesto.M{},
}
s.httpServer = httptest.NewServer(http.HandlerFunc(s.handle))
s.URL = s.httpServer.URL
return s
}
var (
reEvents = regexp.MustCompile(`^/v2/streams/([^/]+)/events$`)
reBatch = regexp.MustCompile(`^/v2/streams/([^/]+)/events/batch$`)
reHead = regexp.MustCompile(`^/v2/streams/([^/]+)/head$`)
reReceipt = regexp.MustCompile(`^/v2/receipts/([^/]+)$`)
reTenantEvents = regexp.MustCompile(`^/v2/tenant/streams/([^/]+)/events$`)
)
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
path := r.URL.Path
switch {
case r.Method == http.MethodPost && path == "/v2/streams":
var body attesto.M
_ = json.NewDecoder(r.Body).Decode(&body)
streamID := s.id("str")
stream := attesto.M{
"streamId": streamID, "systemId": "sys_mock",
"useCase": str(body["useCase"], "mock"), "policyId": str(body["policyId"], "mock-policy"),
"status": "active", "lastSeqNo": float64(0),
"lastEventHash": nil, "lastStreamHeadHash": nil,
"created": true, "mock": true,
}
s.streams[streamID] = stream
s.events[streamID] = nil
writeJSON(w, 201, stream)
case r.Method == http.MethodPost && reBatch.MatchString(path):
streamID := reBatch.FindStringSubmatch(path)[1]
var body struct {
Events []attesto.M `json:"events"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
receipts, code, errBody := s.appendMany(streamID, body.Events)
if errBody != nil {
writeJSON(w, code, errBody)
return
}
writeJSON(w, 201, attesto.M{"accepted": len(receipts), "receipts": receipts})
case r.Method == http.MethodPost && reEvents.MatchString(path):
streamID := reEvents.FindStringSubmatch(path)[1]
var body attesto.M
_ = json.NewDecoder(r.Body).Decode(&body)
receipts, code, errBody := s.appendMany(streamID, []attesto.M{body})
if errBody != nil {
writeJSON(w, code, errBody)
return
}
writeJSON(w, 201, receipts[0])
case r.Method == http.MethodGet && reHead.MatchString(path):
stream, ok := s.streams[reHead.FindStringSubmatch(path)[1]]
if !ok {
writeJSON(w, 404, attesto.M{"detail": "stream not found"})
return
}
writeJSON(w, 200, attesto.M{
"streamId": stream["streamId"], "systemId": stream["systemId"],
"status": stream["status"], "lastSeqNo": stream["lastSeqNo"],
"lastEventHash": stream["lastEventHash"], "lastStreamHeadHash": stream["lastStreamHeadHash"],
"mock": true,
})
case r.Method == http.MethodGet && reReceipt.MatchString(path):
receipt, ok := s.receipts[reReceipt.FindStringSubmatch(path)[1]]
if !ok {
writeJSON(w, 404, attesto.M{"detail": "receipt not found"})
return
}
writeJSON(w, 200, receipt)
case r.Method == http.MethodGet && reTenantEvents.MatchString(path):
list := s.events[reTenantEvents.FindStringSubmatch(path)[1]]
out := make([]attesto.M, 0, len(list))
for _, e := range list {
out = append(out, e.TenantView)
}
writeJSON(w, 200, out)
case r.Method == http.MethodGet && path == "/health":
writeJSON(w, 200, attesto.M{"ok": true, "mock": true})
default:
writeJSON(w, 404, attesto.M{"detail": "not found"})
}
}
func str(v any, fallback string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return fallback
}
func (s *Server) appendMany(streamID string, bodies []attesto.M) ([]attesto.M, int, attesto.M) {
stream, ok := s.streams[streamID]
if !ok {
return nil, 404, attesto.M{"detail": "stream not found"}
}
for _, body := range bodies {
if !safeNumbers(orEmpty(body["payload"])) || !safeNumbers(orEmpty(body["metadata"])) {
return nil, 422, attesto.M{"detail": "unsafe numbers are not permitted in committed payloads"}
}
}
out := make([]attesto.M, 0, len(bodies))
for _, body := range bodies {
out = append(out, s.append(stream, body))
}
return out, 0, nil
}
func orEmpty(v any) any {
if v == nil {
return map[string]any{}
}
return v
}
func (s *Server) append(stream attesto.M, body attesto.M) attesto.M {
payload := orEmpty(body["payload"])
metadata := orEmpty(body["metadata"])
// Idempotent on (source_kind, source_ref), like real ingestion: a resend
// returns the existing event's receipt instead of appending.
sourceKind := str(body["sourceKind"], "sdk")
sourceRef := str(body["sourceRef"], "")
for _, existing := range s.events[stream["streamId"].(string)] {
if sourceRef == "" {
break // anonymous events never dedupe
}
source := existing.Envelope["source"].(attesto.M)
if source["kind"] == sourceKind && source["event_id"] == sourceRef {
return s.receipts[existing.StreamEventID]
}
}
seqNo := int64(stream["lastSeqNo"].(float64)) + 1
ingestedAt := nowISO()
payloadCanonical, _ := attesto.CanonicalJSON(payload)
metadataCanonical, _ := attesto.CanonicalJSON(metadata)
payloadSum := sha256.Sum256(payloadCanonical)
metadataSum := sha256.Sum256(metadataCanonical)
envelope := attesto.M{
"protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock",
"system_id": stream["systemId"],
"stream_id": stream["streamId"],
"use_case": stream["useCase"],
"policy_id": stream["policyId"],
"seq_no": seqNo,
"prev_event_hash": stream["lastEventHash"],
"source": attesto.M{"kind": str(body["sourceKind"], "sdk"), "event_id": str(body["sourceRef"], "")},
"event_type": str(body["eventType"], "inference"),
"occurred_at": str(body["occurredAt"], ingestedAt),
"source_timezone": "Europe/Amsterdam",
"ingested_at": ingestedAt,
"payload_commitment": attesto.M{
"hash_alg": "sha256",
"canonical_payload_hash": hex.EncodeToString(payloadSum[:]),
},
"metadata_commitment": attesto.M{
"hash_alg": "sha256",
"canonical_metadata_hash": hex.EncodeToString(metadataSum[:]),
},
}
eventHash := mustHash(attesto.ProofstreamDomains["event"], envelope)
streamHead := attesto.M{
"protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock",
"stream_id": stream["streamId"],
"seq_no": seqNo,
"event_hash": eventHash,
"prev_stream_head_hash": stream["lastStreamHeadHash"],
"accepted_at": ingestedAt,
}
streamHeadHash := mustHash(attesto.ProofstreamDomains["stream"], streamHead)
streamEventID := s.id("sev")
receiptPayload := attesto.M{
"mock": true,
"protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock",
"system_id": stream["systemId"],
"stream_id": stream["streamId"],
"stream_event_id": streamEventID,
"event_id": str(body["sourceRef"], ""),
"seq_no": seqNo,
"event_hash": eventHash,
"prev_event_hash": stream["lastEventHash"],
"stream_head_hash": streamHeadHash,
"issued_at": ingestedAt,
"signer": attesto.M{"alg": "ed25519", "kid": MockKid, "key_epoch": MockKid},
}
receiptHash := mustHash(attesto.ProofstreamDomains["receipt"], receiptPayload)
canonical, _ := attesto.CanonicalJSON(receiptPayload)
message := append(append([]byte(attesto.ProofstreamDomains["receipt"]), 0), canonical...)
signature := ed25519.Sign(s.priv, message)
wire := attesto.M{
"streamId": stream["streamId"], "streamEventId": streamEventID,
"seqNo": seqNo, "eventHash": eventHash,
"prevEventHash": stream["lastEventHash"], "streamHeadHash": streamHeadHash,
"mock": true,
"receipt": attesto.M{
"payload": receiptPayload,
"receiptHash": receiptHash,
"signature": attesto.M{
"alg": "ed25519", "kid": MockKid, "keyEpoch": MockKid,
"signatureHex": hex.EncodeToString(signature),
},
},
}
s.events[stream["streamId"].(string)] = append(s.events[stream["streamId"].(string)], &mockEvent{
Envelope: envelope, EventHash: eventHash, StreamHeadHash: streamHeadHash,
StreamEventID: streamEventID,
TenantView: attesto.M{
"streamEventId": streamEventID, "seq_no": seqNo,
"event_type": envelope["event_type"],
"source_ref": envelope["source"].(attesto.M)["event_id"],
"event_hash": eventHash, "prev_event_hash": stream["lastEventHash"],
"stream_head_hash": streamHeadHash,
"payload_commitment": envelope["payload_commitment"], "mock": true,
},
})
s.receipts[streamEventID] = wire
stream["lastSeqNo"] = float64(seqNo)
stream["lastEventHash"] = eventHash
stream["lastStreamHeadHash"] = streamHeadHash
return wire
}
// WindowLeaf is one leaf of a built window, with its inclusion proof.
type WindowLeaf struct {
StreamEventID string
SeqNo int64
LeafIndex int
LeafHash string
Proof []attesto.InclusionStep
}
// Window is a built window over all events of a stream so far.
type Window struct {
WindowID string
StreamID string
RootHash string
Leaves []WindowLeaf
}
// BuildWindow folds all events so far into a window with per-leaf inclusion
// proofs (the P1.3 verify functions accept them unchanged).
func (s *Server) BuildWindow(streamID string) (*Window, error) {
s.mu.Lock()
defer s.mu.Unlock()
list := s.events[streamID]
if len(list) == 0 {
return nil, fmt.Errorf("no events to fold")
}
leafHashes := make([]string, len(list))
for i, e := range list {
leafHashes[i] = mustHash(attesto.ProofstreamDomains["window"], attesto.M{
"kind": "leaf", "protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock", "system_id": "sys_mock",
"stream_id": streamID, "stream_event_id": e.StreamEventID,
"seq_no": e.Envelope["seq_no"], "leaf_index": i,
"event_hash": e.EventHash, "stream_head_hash": e.StreamHeadHash,
})
}
proofs := make([][]attesto.InclusionStep, len(leafHashes))
type node struct {
hash string
idx []int
}
level := make([]node, len(leafHashes))
for i, h := range leafHashes {
level[i] = node{h, []int{i}}
}
for len(level) > 1 {
var next []node
for offset := 0; offset < len(level); offset += 2 {
left := level[offset]
if offset+1 >= len(level) {
next = append(next, left) // promote
continue
}
right := level[offset+1]
for _, i := range left.idx {
proofs[i] = append(proofs[i], attesto.InclusionStep{Side: "right", Hash: right.hash})
}
for _, i := range right.idx {
proofs[i] = append(proofs[i], attesto.InclusionStep{Side: "left", Hash: left.hash})
}
parent := mustHash(attesto.ProofstreamDomains["window"], attesto.M{
"kind": "node", "left_hash": left.hash, "right_hash": right.hash,
})
next = append(next, node{parent, append(append([]int{}, left.idx...), right.idx...)})
}
level = next
}
window := &Window{WindowID: s.id("win"), StreamID: streamID, RootHash: level[0].hash}
for i, e := range list {
window.Leaves = append(window.Leaves, WindowLeaf{
StreamEventID: e.StreamEventID, SeqNo: e.Envelope["seq_no"].(int64),
LeafIndex: i, LeafHash: leafHashes[i], Proof: proofs[i],
})
}
return window, nil
}

132
attestotest/server_test.go Normal file
View File

@@ -0,0 +1,132 @@
package attestotest
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"testing"
attesto "go.attesto.eu/sdk"
)
func newClient(t *testing.T, s *Server) *attesto.Client {
t.Helper()
client, err := attesto.NewClient(s.APIKey, attesto.WithBaseURL(s.URL))
if err != nil {
t.Fatal(err)
}
return client
}
func toSignedReceipt(t *testing.T, wire attesto.M) attesto.SignedReceipt {
t.Helper()
raw, _ := json.Marshal(wire["receipt"])
var receipt attesto.SignedReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatal(err)
}
return receipt
}
func TestFullPipelineAgainstTheEmulator(t *testing.T) {
server := NewServer()
defer server.Close()
client := newClient(t, server)
ctx := context.Background()
stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
if err != nil {
t.Fatal(err)
}
receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{
SourceRef: "e1", Payload: attesto.M{"decision": "approve", "score_bp": 8700},
})
if err != nil {
t.Fatal(err)
}
if _, err := client.LogEvents(ctx, stream.StreamID, []attesto.EventInput{
{SourceRef: "e2", Payload: attesto.M{"n": 2}},
{SourceRef: "e3", Payload: attesto.M{"n": 3}},
}); err != nil {
t.Fatal(err)
}
stored, err := client.GetReceipt(ctx, receipt.StreamEventID)
if err != nil {
t.Fatal(err)
}
report := attesto.VerifyReceiptOffline(stored.Receipt, server.PublicKeyHex)
if !report.OK {
t.Fatalf("offline verification failed: %v", report.Problems)
}
events, err := client.ListTenantStreamEvents(ctx, stream.StreamID, 100, 0)
if err != nil {
t.Fatal(err)
}
plain := make([]map[string]any, len(events))
for i, e := range events {
plain[i] = e
}
comp := attesto.VerifyCompleteness(plain, 1, 3)
if !comp.OK {
t.Fatalf("completeness failed: %v", comp.Problems)
}
}
func TestInclusionProofsFromBuiltWindowVerify(t *testing.T) {
server := NewServer()
defer server.Close()
client := newClient(t, server)
ctx := context.Background()
stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
for i := 0; i < 5; i++ { // odd leaf count exercises the promote rule
if _, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{
SourceRef: "e", Payload: attesto.M{"i": i},
}); err != nil {
t.Fatal(err)
}
}
window, err := server.BuildWindow(stream.StreamID)
if err != nil {
t.Fatal(err)
}
for _, leaf := range window.Leaves {
ok, err := attesto.VerifyInclusionProof(leaf.LeafHash, leaf.Proof, window.RootHash)
if err != nil || !ok {
t.Fatalf("leaf %d failed inclusion: ok=%v err=%v", leaf.LeafIndex, ok, err)
}
}
}
func TestMockReceiptsCannotPassAsReal(t *testing.T) {
server := NewServer()
defer server.Close()
client := newClient(t, server)
ctx := context.Background()
stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{SourceRef: "e1"})
if err != nil {
t.Fatal(err)
}
stored, err := client.GetReceipt(ctx, receipt.StreamEventID)
if err != nil {
t.Fatal(err)
}
// Structurally marked.
if stored.Receipt.Payload["mock"] != true {
t.Error("mock receipt payload must declare mock: true")
}
signer, _ := stored.Receipt.Payload["signer"].(map[string]any)
if signer["kid"] != MockKid {
t.Errorf("kid = %v, want %s", signer["kid"], MockKid)
}
// Rejected against a different ("real") witness key.
realPub, _, _ := ed25519.GenerateKey(rand.Reader)
report := attesto.VerifyReceiptOffline(stored.Receipt, hex.EncodeToString(realPub))
if report.OK {
t.Fatal("mock receipt verified against a real key — must never happen")
}
}

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

@@ -0,0 +1,97 @@
//go:build js && wasm
// attesto-verify-wasm [P3.1] — the verifier-only WebAssembly build.
//
// Exposes the offline verification functions (and nothing else: no client,
// no CLI, no network capability is ever invoked) on a global
// `attestoVerify` object for the docs-site /verify drop-zone. Every
// function takes JSON strings and returns a JSON string, so the JS side
// stays a thin shell.
package main
import (
"encoding/json"
"syscall/js"
attesto "go.attesto.eu/sdk"
)
func respond(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return `{"ok":false,"problems":["internal: response marshal failed"]}`
}
return string(raw)
}
func fail(problem string) string {
return respond(map[string]any{"ok": false, "problems": []string{problem}})
}
// verifyReceipt(receiptJSON, publicKeyHex) -> VerifyReport JSON
func verifyReceipt(_ js.Value, args []js.Value) any {
if len(args) != 2 {
return fail("usage: verifyReceipt(receiptJSON, publicKeyHex)")
}
var receipt attesto.SignedReceipt
if err := json.Unmarshal([]byte(args[0].String()), &receipt); err != nil {
return fail("receipt is not valid JSON: " + err.Error())
}
return respond(attesto.VerifyReceiptOffline(receipt, args[1].String()))
}
// verifyInclusion(leafHash, proofJSON, rootHash) -> {ok, problems}
func verifyInclusion(_ js.Value, args []js.Value) any {
if len(args) != 3 {
return fail("usage: verifyInclusion(leafHash, proofJSON, rootHash)")
}
var proof []attesto.InclusionStep
if err := json.Unmarshal([]byte(args[1].String()), &proof); err != nil {
return fail("proof is not valid JSON: " + err.Error())
}
ok, err := attesto.VerifyInclusionProof(args[0].String(), proof, args[2].String())
if err != nil {
return fail(err.Error())
}
return respond(map[string]any{"ok": ok, "problems": []string{}})
}
// verifyCheckpointRoot(windowHashesJSON, expectedRoot) -> {ok, problems}
func verifyCheckpointRoot(_ js.Value, args []js.Value) any {
if len(args) != 2 {
return fail("usage: verifyCheckpointRoot(windowHashesJSON, expectedRoot)")
}
var hashes []string
if err := json.Unmarshal([]byte(args[0].String()), &hashes); err != nil {
return fail("windowHashes is not valid JSON: " + err.Error())
}
ok, err := attesto.VerifyCheckpointRoot(hashes, args[1].String())
if err != nil {
return fail(err.Error())
}
return respond(map[string]any{"ok": ok, "problems": []string{}})
}
// verifyCompleteness(eventsJSON, fromSeqNo, toSeqNo) -> VerifyReport JSON
func verifyCompleteness(_ js.Value, args []js.Value) any {
if len(args) != 3 {
return fail("usage: verifyCompleteness(eventsJSON, fromSeqNo, toSeqNo)")
}
var events []map[string]any
if err := json.Unmarshal([]byte(args[0].String()), &events); err != nil {
return fail("events is not valid JSON: " + err.Error())
}
return respond(attesto.VerifyCompleteness(events, args[1].Int(), args[2].Int()))
}
func main() {
exports := js.Global().Get("Object").New()
exports.Set("verifyReceipt", js.FuncOf(verifyReceipt))
exports.Set("verifyInclusion", js.FuncOf(verifyInclusion))
exports.Set("verifyCheckpointRoot", js.FuncOf(verifyCheckpointRoot))
exports.Set("verifyCompleteness", js.FuncOf(verifyCompleteness))
exports.Set("sdkVersion", attesto.SDKVersion)
js.Global().Set("attestoVerify", exports)
// Keep the runtime alive for calls from JS.
select {}
}

View File

@@ -0,0 +1,248 @@
package main
// [D.5] `attesto connector init <slug>` — scaffold a marketplace-ready
// connector: a v2 manifest that passes connectorkit validation locally, a
// signed-webhook handler stub built on the P1.4 verification helper, and a
// README pointing at the submission flow. The local validation run is the
// same code the marketplace runs, so a green scaffold is a green
// pre-submission check.
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"go.attesto.eu/sdk/connectorkit"
)
var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`)
func connectorManifestTemplate(slug, name, category string) connectorkit.Manifest {
return connectorkit.Manifest{
SchemaVersion: "attesto.connector.v2",
Slug: slug,
Name: name,
Version: "0.1.0",
AssetType: "connector",
Category: category,
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
Description: fmt.Sprintf(
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"},
Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug},
Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"},
Capabilities: []string{
"proofstream", "signed-webhook", "offline-verification",
},
Evidence: map[string]bool{
"offlineVerification": true,
"receipts": true,
"witnessCompatible": true,
},
Security: map[string]bool{
"dependencyScan": true,
"secretScan": true,
"secretsServerSide": true,
},
SupportedLanguages: []string{"en"},
Provider: map[string]any{
"id": slug,
"name": name,
"websiteUrl": "https://example.com",
},
Auth: map[string]any{
"mode": "signed-webhook",
"scopes": []string{"webhook:read"},
},
Sync: map[string]any{
"modes": []string{"webhook"},
"supportsReplay": true,
"rateLimitPolicy": "Provider webhook retries and Attesto idempotency keys",
},
EventTypes: []string{slug + ".event"},
SourceTime: map[string]any{
"required": true,
"timezonePolicy": "source-timestamp-with-offset-required",
},
ConfigSchema: map[string]any{
"type": "object",
"required": []string{"resourceRef"},
"properties": map[string]any{
"resourceRef": map[string]any{"type": "string"},
},
},
SecretSchema: map[string]any{
"type": "object",
"required": []string{"webhookSecret"},
"properties": map[string]any{
"webhookSecret": map[string]any{"type": "string", "secret": true},
},
},
Diagnostics: map[string]any{
"providerAuthStatus": true,
"replayConflictCheck": true,
"revocationCheck": true,
"syncLag": true,
"testConnection": true,
},
Runtime: map[string]any{
"officialConnectorKit": true,
"sdkSurfaces": []string{"python", "typescript", "go", "cli"},
"requiredMethods": []string{
"metadata", "validateConfig", "testConnection", "sync",
"handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke",
},
// A scaffold cannot honestly claim a green assurance canary; this
// stays "pending" (the one expected validation finding) until the
// connector has real canary evidence.
"canary": map[string]any{
"status": "pending",
"ref": "CHANGE ME: assurance canary evidence ref",
},
},
InstallRequirements: map[string]any{
"tenantLoginRequired": true,
"entitlementRequired": true,
},
Changelog: []map[string]any{
{"version": "0.1.0", "changes": []string{"Initial scaffold."}},
},
}
}
const webhookHandlerStub = `"""Signed-webhook handler stub for the %s connector.
Verification uses the Attesto SDK's P1.4 helper — the same scheme the
platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s
skew window and constant-time comparison.
"""
import os
from attesto.webhooks import verify_webhook
def handle(headers: dict[str, str], body: bytes) -> dict:
if not verify_webhook(
body=body,
headers=headers,
secret=os.environ["WEBHOOK_SECRET"],
):
raise PermissionError("invalid webhook signature or stale timestamp")
# The payload is now authentic: turn it into a proofstream event here.
return {"ok": True}
`
const connectorReadmeStub = `# %s
Scaffolded by ` + "`attesto connector init`" + `.
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
event types — search for CHANGE ME).
2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the
signed-webhook entry point; verification is already wired).
3. Re-run the pre-submission check at any time:
attesto connector init --validate-only %s
4. Submit through the marketplace flow (docs.attesto.eu/manuals/connectors.html).
`
func (a *app) connectorInit(args []string) error {
fs := flag.NewFlagSet("connector init", flag.ContinueOnError)
fs.SetOutput(a.err)
name := fs.String("name", "", "human-readable connector name (default: derived from slug)")
category := fs.String("category", "devops", "marketplace category")
dir := fs.String("dir", "", "output directory (default: ./<slug>)")
validateOnly := fs.String("validate-only", "", "validate an existing <dir>/attesto.connector.json and exit")
// Accept the slug positionally before flags: `connector init my-slug --category crm`.
slug := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
slug = args[0]
args = args[1:]
}
if err := fs.Parse(args); err != nil {
return err
}
if *validateOnly != "" {
raw, err := os.ReadFile(filepath.Join(*validateOnly, "attesto.connector.json"))
if err != nil {
return err
}
var manifest connectorkit.Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return err
}
result := connectorkit.ValidateManifest(manifest)
if err := a.write(result); err != nil {
return err
}
if !result.OK {
return errors.New("manifest validation failed")
}
return nil
}
if slug == "" && fs.NArg() == 1 {
slug = fs.Arg(0)
}
if slug == "" {
return errors.New("usage: attesto connector init <slug> [--name ...] [--category ...]")
}
if !connectorSlugPattern.MatchString(slug) {
return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern)
}
connectorName := *name
if connectorName == "" {
connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck
}
manifest := connectorManifestTemplate(slug, connectorName, *category)
result := connectorkit.ValidateManifest(manifest)
// The only acceptable finding on a fresh scaffold is the pending canary —
// everything else must already satisfy the marketplace validator.
for _, finding := range result.Findings {
if finding.Code != "runtime.canary" {
return fmt.Errorf("internal error: scaffold template failed validation: %+v", result.Findings)
}
}
outDir := *dir
if outDir == "" {
outDir = slug
}
if _, err := os.Stat(filepath.Join(outDir, "attesto.connector.json")); err == nil {
return fmt.Errorf("%s/attesto.connector.json already exists", outDir)
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, "attesto.connector.json"), append(raw, '\n'), 0o644); err != nil {
return err
}
handler := fmt.Sprintf(webhookHandlerStub, connectorName)
if err := os.WriteFile(filepath.Join(outDir, "webhook_handler.py"), []byte(handler), 0o644); err != nil {
return err
}
readme := fmt.Sprintf(connectorReadmeStub, connectorName, outDir)
if err := os.WriteFile(filepath.Join(outDir, "README.md"), []byte(readme), 0o644); err != nil {
return err
}
return a.write(map[string]any{
"created": outDir,
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
"validation": result,
"nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " +
"assurance canary, re-run with --validate-only until OK",
})
}

View File

@@ -0,0 +1,49 @@
package main
// [D.5] connector init scaffolds a manifest whose ONLY validation finding is
// the pending canary, and the generated webhook stub calls the real P1.4
// helper with its actual signature.
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"go.attesto.eu/sdk/connectorkit"
)
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
dir := filepath.Join(t.TempDir(), "my-crm")
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
if err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
if err != nil {
t.Fatal(err)
}
var manifest connectorkit.Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
result := connectorkit.ValidateManifest(manifest)
for _, finding := range result.Findings {
if finding.Code != "runtime.canary" {
t.Fatalf("unexpected finding: %+v", finding)
}
}
stub, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(stub), "from attesto.webhooks import verify_webhook") {
t.Fatal("stub does not use the P1.4 helper")
}
// re-running must refuse to overwrite
if err := a.connectorInit([]string{"my-crm-evidence", "--dir", dir}); err == nil {
t.Fatal("expected overwrite refusal")
}
}

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.4.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":
@@ -135,12 +137,22 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
return a.quorum(ctx, args[1:]) return a.quorum(ctx, args[1:])
case "ivc": case "ivc":
return a.ivc(ctx, args[1:]) return a.ivc(ctx, args[1:])
case "connector":
// [D.5] scaffold + local pre-submission validation
if len(args) > 1 && args[1] == "init" {
return a.connectorInit(args[2:])
}
return errors.New("connector subcommand required (init)")
case "connectors": case "connectors":
return a.connectors(ctx, args[1:]) return a.connectors(ctx, args[1:])
case "local-vault": case "local-vault":
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 "report":
return a.report(ctx, args[1:])
case "readiness": case "readiness":
return a.readiness(args[1:]) return a.readiness(args[1:])
default: default:
@@ -164,6 +176,30 @@ func (a *app) verify(ctx context.Context, args []string) error {
return errors.New("--file is required") return errors.New("--file is required")
} }
return a.write(verifyTruthPackageZip(*file)) return a.write(verifyTruthPackageZip(*file))
case "file":
// [P3.4] Verify a portable *.attesto.json receipt export offline.
fs := flag.NewFlagSet("verify file", flag.ContinueOnError)
fs.SetOutput(a.err)
file := fs.String("file", "", "portable receipt export (*.attesto.json)")
publicKeyHex := fs.String("public-key-hex", "", "pinned witness key (omitting it verifies against the file's embedded hint)")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *file == "" {
return errors.New("--file is required")
}
raw, err := os.ReadFile(*file)
if err != nil {
return err
}
report := attesto.VerifyReceiptExport(raw, *publicKeyHex)
if err := a.write(report); err != nil {
return err
}
if !report.OK {
return errors.New("verification failed")
}
return nil
default: default:
_ = ctx _ = ctx
return fmt.Errorf("unknown verify subcommand: %s", args[0]) return fmt.Errorf("unknown verify subcommand: %s", args[0])
@@ -410,6 +446,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")

198
cmd/attesto/report.go Normal file
View File

@@ -0,0 +1,198 @@
package main
// [P2.2] `attesto report article12` — deterministic evidence-report templating
// (never LLM-generated). The report states what is recorded and independently
// verifiable; it never asserts conformity.
import (
"context"
"errors"
"flag"
"fmt"
"os"
"sort"
"strings"
attesto "go.attesto.eu/sdk"
)
const reportDisclaimer = "This report lists evidence recorded and independently " +
"verifiable on this stream. It does not assert conformity with any regulation: " +
"Attesto attests records; assessing legal obligations is for your advisors."
var article12Elements = [][2]string{
{"(a) period of each use", "event timestamps (occurred_at / ingested_at), hash-chained per stream"},
{"(b) reference database checks", "input commitments on model_decision events"},
{"(c) input data for the check", "payload commitments (canonical SHA-256, recomputable client-side)"},
{"(d) identification of persons involved", "operator/actor references on decision and override events"},
}
func (a *app) report(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "article12" {
return errors.New("usage: attesto report article12 --stream <id> [--from ts] [--to ts] [--output report.md]")
}
fs := flag.NewFlagSet("report article12", flag.ContinueOnError)
fs.SetOutput(a.err)
streamID := fs.String("stream", "", "stream id")
fromTs := fs.String("from", "", "RFC3339 window start")
toTs := fs.String("to", "", "RFC3339 window end")
output := fs.String("output", "", "write the markdown report to this file")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *streamID == "" {
return errors.New("--stream is required")
}
client, err := a.bearerClient()
if err != nil {
return err
}
report, err := buildArticle12Report(ctx, client, *streamID, *fromTs, *toTs)
if err != nil {
return err
}
if *output != "" {
if err := os.WriteFile(*output, []byte(report), 0o644); err != nil {
return err
}
return a.write(map[string]any{"ok": true, "output": *output})
}
_, err = fmt.Fprint(a.out, report)
return err
}
func buildArticle12Report(ctx context.Context, client *attesto.Client, streamID, fromTs, toTs string) (string, error) {
var events []attesto.M
it := client.IterTenantStreamEvents(streamID, 200)
for {
event, err := it.Next(ctx)
if err != nil {
return "", err
}
if event == nil {
break
}
ts, _ := firstString(event, "occurred_at", "occurredAt", "ingested_at", "ingestedAt")
if ts != "" {
if fromTs != "" && ts < fromTs {
continue
}
if toTs != "" && ts > toTs {
continue
}
}
events = append(events, event)
}
counts := map[string]int{}
var seqs []int
completenessInput := make([]map[string]any, 0, len(events))
for _, event := range events {
eventType, _ := firstString(event, "event_type", "eventType")
if eventType == "" {
eventType = "(untyped)"
}
counts[eventType]++
seq := int(asFloatValue(event["seq_no"], event["seqNo"]))
seqs = append(seqs, seq)
prev, _ := firstString(event, "prev_event_hash", "prevEventHash")
hash, _ := firstString(event, "event_hash", "eventHash")
completenessInput = append(completenessInput, map[string]any{
"seq_no": seq, "prev_event_hash": prev, "event_hash": hash,
})
}
sort.Ints(seqs)
completenessLine := "no events in range"
if len(seqs) > 0 {
comp := attesto.VerifyCompleteness(completenessInput, seqs[0], seqs[len(seqs)-1])
if comp.OK {
completenessLine = fmt.Sprintf("PASS — sequence %d..%d is gap-free and hash-chained", seqs[0], seqs[len(seqs)-1])
} else {
completenessLine = "FAIL — " + strings.Join(comp.Problems, ", ")
}
}
var checkpointRows []string
cit := client.IterTenantCheckpoints(streamID, 200)
for {
checkpoint, err := cit.Next(ctx)
if err != nil {
break // endpoint optional on older deployments
}
if checkpoint == nil {
break
}
hash, _ := firstString(checkpoint, "checkpoint_hash", "checkpointHash", "rootHash")
tx, _ := firstString(checkpoint, "tx_hash", "txHash")
if tx == "" {
tx = "not yet anchored"
}
block := "—"
if b := asFloatValue(checkpoint["block_number"], checkpoint["blockNumber"]); b > 0 {
block = fmt.Sprintf("%d", int64(b))
}
checkpointRows = append(checkpointRows, fmt.Sprintf("| `%s` | `%s` | %s |", hash, tx, block))
}
if len(checkpointRows) == 0 {
checkpointRows = []string{"| (no checkpoints in range) | — | — |"}
}
window := "stream start"
if fromTs != "" {
window = fromTs
}
windowEnd := "now"
if toTs != "" {
windowEnd = toTs
}
var b strings.Builder
fmt.Fprintf(&b, "# Evidence report — stream `%s`\n\n%s\n\n", streamID, reportDisclaimer)
fmt.Fprintf(&b, "Window: %s → %s\n\n", window, windowEnd)
b.WriteString("## Logging coverage (EU AI Act Article 12(2))\n\n| Element | Evidence recorded |\n|---|---|\n")
for _, row := range article12Elements {
fmt.Fprintf(&b, "| %s | %s |\n", row[0], row[1])
}
b.WriteString("\n## Events in range\n\n| Event type | Count |\n|---|---|\n")
types := make([]string, 0, len(counts))
for t := range counts {
types = append(types, t)
}
sort.Strings(types)
for _, t := range types {
fmt.Fprintf(&b, "| `%s` | %d |\n", t, counts[t])
}
fmt.Fprintf(&b, "| **total** | **%d** |\n\n", len(events))
fmt.Fprintf(&b, "**Completeness (no omissions):** %s\n\n", completenessLine)
b.WriteString("## Verification path per checkpoint\n\n| Checkpoint | Anchor tx | Block |\n|---|---|---|\n")
b.WriteString(strings.Join(checkpointRows, "\n"))
b.WriteString("\n\n## Replay these checks yourself\n\n```bash\n")
b.WriteString("go get go.attesto.eu/sdk # or: pip install attesto / npm i @attesto/sdk\n")
fmt.Fprintf(&b, "# offline, no Attesto call: VerifyReceiptOffline / VerifyInclusionProof /\n# VerifyCompleteness over stream %s\n", streamID)
b.WriteString("attesto verify truth-package --file <export.zip>\n```\n\n")
fmt.Fprintf(&b, "_Generated by attesto-go/%s (deterministic template; no AI involved)._\n", attesto.SDKVersion)
return b.String(), nil
}
func firstString(m map[string]any, keys ...string) (string, bool) {
for _, key := range keys {
if s, ok := m[key].(string); ok && s != "" {
return s, true
}
}
return "", false
}
func asFloatValue(values ...any) float64 {
for _, v := range values {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
}
}
return 0
}

View File

@@ -0,0 +1,58 @@
package main
import (
"context"
"fmt"
"strings"
"testing"
attesto "go.attesto.eu/sdk"
"go.attesto.eu/sdk/attestotest"
)
func TestArticle12ReportWordingAndStructure(t *testing.T) {
server := attestotest.NewServer()
defer server.Close()
client, err := attesto.NewBearerClient("dummy-tenant-bearer-token", attesto.WithBaseURL(server.URL))
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
if err != nil {
t.Fatal(err)
}
decision := attesto.ModelDecision{Model: "m", Decision: "approve", ConfidenceBp: 8700}
payload, err := decision.ToPayload()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
// Distinct source refs: identical refs are one event (idempotent ingestion).
if _, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{
SourceRef: fmt.Sprintf("e-%d", i), EventType: decision.EventType(), Payload: payload,
}); err != nil {
t.Fatal(err)
}
}
report, err := buildArticle12Report(ctx, client, stream.StreamID, "", "")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"Article 12(2)",
"| `attesto.model_decision` | 2 |",
"PASS — sequence 1..2 is gap-free and hash-chained",
"deterministic template; no AI involved",
"attesto verify truth-package",
} {
if !strings.Contains(report, want) {
t.Errorf("report missing %q", want)
}
}
lower := strings.ToLower(report)
if strings.Contains(lower, "compliant") || strings.Contains(lower, "compliance guaranteed") {
t.Error("claims discipline violated: report must never say compliant")
}
}

135
events.go Normal file
View File

@@ -0,0 +1,135 @@
package attesto
// [P2.2] Typed compliance events — SDK-side conventions, no backend change.
// Each ToPayload() returns a plain payload map with regulation_refs included,
// validated against the committed-payload number policy. Recording these never
// claims conformity: Attesto attests records.
// Typed event type identifiers.
const (
EventTypeModelDecision = "attesto.model_decision"
EventTypeHumanOverride = "attesto.human_override"
EventTypeIncidentReport = "attesto.incident_report"
EventTypeDataAccess = "attesto.data_access"
)
// Regulation references per typed event (conventions for reports/auditors).
var (
RefsModelDecision = []string{"EU-AI-Act:Art.12", "EU-AI-Act:Art.14"}
RefsHumanOverride = []string{"EU-AI-Act:Art.14"}
RefsIncidentReport = []string{"NIS2:Art.23", "EU-AI-Act:Art.62"}
RefsDataAccess = []string{"GDPR:Art.30", "GDPR:Art.6"}
)
func finishPayload(payload M, refs []string, extra M) (M, error) {
for key, value := range extra {
payload[key] = value
}
out := M{}
for key, value := range payload {
if value != nil && value != "" {
out[key] = value
}
}
out["regulation_refs"] = refs
if err := AssertCommitmentSafeNumbers(map[string]any(out), "$"); err != nil {
return nil, err
}
return out, nil
}
// ModelDecision is one model-driven decision (commitments only; raw inputs and
// outputs never leave your process).
type ModelDecision struct {
Model string
InputCommitment map[string]string
OutputCommitment map[string]string
Decision string
ConfidenceBp int // basis points keep integers commitment-safe
HumanInLoop bool
OperatorRef string
Extra M
}
func (e ModelDecision) EventType() string { return EventTypeModelDecision }
func (e ModelDecision) ToPayload() (M, error) {
return finishPayload(M{
"model": e.Model,
"input_commitment": orNil(e.InputCommitment),
"output_commitment": orNil(e.OutputCommitment),
"decision": e.Decision,
"confidence_bp": e.ConfidenceBp,
"human_in_loop": e.HumanInLoop,
"operator_ref": e.OperatorRef,
}, RefsModelDecision, e.Extra)
}
// HumanOverride records a human overriding a model decision (Art. 14).
type HumanOverride struct {
OriginalEventRef string
OperatorRef string
JustificationCommitment map[string]string
NewDecision string
Extra M
}
func (e HumanOverride) EventType() string { return EventTypeHumanOverride }
func (e HumanOverride) ToPayload() (M, error) {
return finishPayload(M{
"original_event_ref": e.OriginalEventRef,
"operator_ref": e.OperatorRef,
"justification_commitment": orNil(e.JustificationCommitment),
"new_decision": e.NewDecision,
}, RefsHumanOverride, e.Extra)
}
// IncidentReport is a reportable incident with NIS2-style field names.
type IncidentReport struct {
Severity string
Category string
DetectedAt string
SummaryCommitment map[string]string
AffectedService string
Extra M
}
func (e IncidentReport) EventType() string { return EventTypeIncidentReport }
func (e IncidentReport) ToPayload() (M, error) {
return finishPayload(M{
"severity": e.Severity,
"category": e.Category,
"detected_at": e.DetectedAt,
"summary_commitment": orNil(e.SummaryCommitment),
"affected_service": e.AffectedService,
}, RefsIncidentReport, e.Extra)
}
// DataAccess records an access to personal or regulated data.
type DataAccess struct {
SubjectRefCommitment map[string]string
Purpose string
LegalBasis string
AccessorRef string
Extra M
}
func (e DataAccess) EventType() string { return EventTypeDataAccess }
func (e DataAccess) ToPayload() (M, error) {
return finishPayload(M{
"subject_ref_commitment": orNil(e.SubjectRefCommitment),
"purpose": e.Purpose,
"legal_basis": e.LegalBasis,
"accessor_ref": e.AccessorRef,
}, RefsDataAccess, e.Extra)
}
func orNil(m map[string]string) any {
if len(m) == 0 {
return nil
}
return m
}

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() {

161
export.go Normal file
View File

@@ -0,0 +1,161 @@
package attesto
// [P3.4] Portable receipt export — a self-contained `*.attesto.json`.
// Mirrors attesto.export (Python) and export.ts (TypeScript); the
// receipt-export.json parity corpus is normative for all three.
import (
"encoding/json"
"fmt"
"os"
"time"
)
const (
ExportFormat = "attesto-receipt-export"
ExportFormatVersion = 1
)
// ReceiptExport is the portable envelope. Receipt is kept as raw JSON so the
// export carries the receipt verbatim, exactly as the API returned it.
type ReceiptExport struct {
Format string `json:"format"`
FormatVersion int `json:"format_version"`
ExportedAt string `json:"exported_at"`
StreamID any `json:"stream_id,omitempty"`
SeqNo any `json:"seq_no,omitempty"`
EventHash any `json:"event_hash,omitempty"`
WitnessPublicKeyHex string `json:"witness_public_key_hex,omitempty"`
Receipt json.RawMessage `json:"receipt"`
Payload M `json:"payload,omitempty"`
PayloadCommitment M `json:"payload_commitment,omitempty"`
}
func exportPick(source M, keys ...string) any {
for _, key := range keys {
if value, ok := source[key]; ok {
return value
}
}
return nil
}
// ExportReceiptFile builds a portable export from a receipt's raw JSON (as
// returned by the API) and optionally writes it to path (empty = no write).
func ExportReceiptFile(receiptJSON []byte, path string, witnessPublicKeyHex string) (ReceiptExport, error) {
var parsed struct {
Payload M `json:"payload"`
}
if err := json.Unmarshal(receiptJSON, &parsed); err != nil {
return ReceiptExport{}, fmt.Errorf("receipt is not valid JSON: %w", err)
}
export := ReceiptExport{
Format: ExportFormat,
FormatVersion: ExportFormatVersion,
ExportedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
StreamID: exportPick(parsed.Payload, "stream_id", "streamId"),
SeqNo: exportPick(parsed.Payload, "seq_no", "seqNo"),
EventHash: exportPick(parsed.Payload, "event_hash", "eventHash"),
WitnessPublicKeyHex: witnessPublicKeyHex,
Receipt: json.RawMessage(receiptJSON),
}
if path != "" {
raw, err := json.MarshalIndent(export, "", " ")
if err != nil {
return ReceiptExport{}, err
}
if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil {
return ReceiptExport{}, err
}
}
return export, nil
}
// VerifyReceiptExport verifies a portable export offline. publicKeyHex == ""
// falls back to the embedded hint (self-contained mode — proves internal
// consistency against the key the file itself names).
func VerifyReceiptExport(exportJSON []byte, publicKeyHex string) VerifyReport {
var export struct {
Format string `json:"format"`
FormatVersion int `json:"format_version"`
StreamID any `json:"stream_id"`
SeqNo any `json:"seq_no"`
EventHash any `json:"event_hash"`
WitnessPublicKeyHex string `json:"witness_public_key_hex"`
Receipt json.RawMessage `json:"receipt"`
Payload M `json:"payload"`
PayloadCommitment M `json:"payload_commitment"`
}
kind := VerifyKind("receipt-export")
if publicKeyHex == "" {
kind = "receipt-export-selfcontained"
}
fail := func(problems ...string) VerifyReport {
return VerifyReport{Kind: kind, OK: false, Problems: problems}
}
if err := json.Unmarshal(exportJSON, &export); err != nil {
return fail("export is not valid JSON: " + err.Error())
}
var problems []string
if export.Format != ExportFormat {
problems = append(problems, "not an attesto receipt export (format field)")
}
if export.FormatVersion != ExportFormatVersion {
problems = append(problems, fmt.Sprintf("unsupported export format_version: %d", export.FormatVersion))
}
if len(export.Receipt) == 0 {
problems = append(problems, "export carries no receipt object")
}
if len(problems) > 0 {
return fail(problems...)
}
key := publicKeyHex
if key == "" {
key = export.WitnessPublicKeyHex
}
if key == "" {
return fail("no public key supplied and no embedded hint")
}
var receipt SignedReceipt
if err := json.Unmarshal(export.Receipt, &receipt); err != nil {
return fail("receipt is not valid JSON: " + err.Error())
}
report := VerifyReceiptOffline(receipt, key)
problems = append(problems, report.Problems...)
linkage := []struct {
name string
outer any
keys []string
}{
{"stream_id", export.StreamID, []string{"stream_id", "streamId"}},
{"seq_no", export.SeqNo, []string{"seq_no", "seqNo"}},
{"event_hash", export.EventHash, []string{"event_hash", "eventHash"}},
}
for _, link := range linkage {
inner := exportPick(receipt.Payload, link.keys...)
if link.outer == nil || inner == nil {
continue
}
// JSON numbers decode as float64 on both sides, so == is sound here.
if fmt.Sprint(link.outer) != fmt.Sprint(inner) {
problems = append(problems, "export linkage mismatch: "+link.name)
}
}
if export.Payload != nil && export.PayloadCommitment != nil {
ok, err := VerifyPayloadCommitment(export.Payload, M{"payload_commitment": export.PayloadCommitment})
if err != nil || !ok {
problems = append(problems, "embedded payload does not match payload_commitment")
}
}
return VerifyReport{
Kind: kind,
OK: len(problems) == 0,
ReceiptHash: report.ReceiptHash,
EventHash: report.EventHash,
Problems: problems,
}
}

77
export_parity_test.go Normal file
View File

@@ -0,0 +1,77 @@
package attesto
// [P3.4] Receipt-export parity corpus — Go verifier.
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestReceiptExportParity(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipt-export.json"))
if err != nil {
t.Fatalf("read corpus: %v", err)
}
var corpus struct {
Cases []struct {
ID string `json:"id"`
ExpectOK bool `json:"expect_ok"`
PublicKeyHex *string `json:"public_key_hex"`
Export json.RawMessage `json:"export"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &corpus); err != nil {
t.Fatalf("parse corpus: %v", err)
}
if len(corpus.Cases) < 5 {
t.Fatalf("expected >=5 cases, got %d", len(corpus.Cases))
}
for _, testCase := range corpus.Cases {
t.Run(testCase.ID, func(t *testing.T) {
key := ""
if testCase.PublicKeyHex != nil {
key = *testCase.PublicKeyHex
}
report := VerifyReceiptExport(testCase.Export, key)
if report.OK != testCase.ExpectOK {
t.Fatalf("ok=%v want %v (problems: %v)", report.OK, testCase.ExpectOK, report.Problems)
}
if testCase.PublicKeyHex == nil && testCase.ExpectOK && report.Kind != "receipt-export-selfcontained" {
t.Fatalf("kind=%q, want receipt-export-selfcontained", report.Kind)
}
})
}
}
func TestExportReceiptFileRoundTrip(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "golden-vectors", "sdk-parity", "receipt-export.json"))
if err != nil {
t.Fatalf("read corpus: %v", err)
}
var corpus struct {
Cases []struct {
PublicKeyHex *string `json:"public_key_hex"`
Export struct {
Receipt json.RawMessage `json:"receipt"`
} `json:"export"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &corpus); err != nil {
t.Fatalf("parse corpus: %v", err)
}
valid := corpus.Cases[0]
path := filepath.Join(t.TempDir(), "receipt.attesto.json")
if _, err := ExportReceiptFile(valid.Export.Receipt, path, *valid.PublicKeyHex); err != nil {
t.Fatalf("export: %v", err)
}
exported, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read export: %v", err)
}
report := VerifyReceiptExport(exported, *valid.PublicKeyHex)
if !report.OK {
t.Fatalf("round-trip verify failed: %v", report.Problems)
}
}

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

165
heads.go Normal file
View File

@@ -0,0 +1,165 @@
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.EventHash == storedHash {
// Benign idempotent replay: the server deduplicated a resend and
// returned the same receipt for the same event.
return nil
}
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
}

99
heads_test.go Normal file
View File

@@ -0,0 +1,99 @@
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")
}
}
func TestExactReplayOfStoredHeadIsBenign(t *testing.T) {
// [P3.3 regression] A deduplicated resend returns the same receipt; the
// head tracker must treat (same seqNo, same eventHash) as a no-op.
store := NewMemoryHeadStore()
first := EventReceipt{StreamID: "str_x", SeqNo: 1, EventHash: "h1"}
if err := checkAndAdvanceHead(store, first); err != nil {
t.Fatal(err)
}
if err := checkAndAdvanceHead(store, first); err != nil {
t.Fatalf("exact replay must be benign, got %v", err)
}
fork := EventReceipt{StreamID: "str_x", SeqNo: 1, EventHash: "h2"}
if err := checkAndAdvanceHead(store, fork); err == nil {
t.Fatal("same seq with different hash must be a fork")
}
}

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.4.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")
}
}
}