29 Commits
Author SHA1 Message Date
CodexandClaude Fable 5 4d4a118e71 release(sdk): 0.5.0 across Python, TypeScript, Go and the CLI
The published 0.4.0 predates the Attesto 3 verifier surfaces (disclosure v1/v2,
bundle provenance root and offline revocation, effective assurance, Pedersen
opening verification) and, on PyPI, is missing ten modules outright. 0.5.0 is
the lockstep version the registry-readiness gate requires across all four.
Wheel and npm pack pass the artifact policy; publishing waits on registry
credentials. The npm allowlist now accepts LICENSE, which npm always packs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:12:35 +02:00
CodexandClaude Fable 5 2894298317 feat(s15): ADR-0014 accepted — bundle provenance root, key lifecycle, offline revocation
Option C. A verifier bundle over a provenance stream carries provenance_root
(Merkle over one leaf per event: seq_no, capsule_root, installation, key,
assurance, occurred_at, under attesto.provenance.v1.bundle_tree), the event
count and vault_key_lifecycle, all conditional so legacy bundle hashes are
unchanged. Rust is normative (edge/src/bundle_tree.rs, nine golden vectors);
Python, Go and TypeScript verify an inclusion and apply the frozen revocation
rule against the receipt time offline. The inclusion endpoint in router.py
lands with the next commit, which carries the shared router edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 14:08:14 +02:00
CodexandClaude Opus 5 974095c5f9 feat(sdk): derive assurance in TypeScript and Go, not only Python
Python derived the assurance ladder and the other two clients did not, so a
TypeScript verifier -- which is what the product UI is -- had no way to
present it without inventing one. The rule that L3 is derived and never
signed only holds if every client applies it, so this is the property rather
than tidiness.

All three now report four facts kept apart: what the vault signed, whether a
quorum was met, whether an anchor confirmed, and what a verifier may
therefore report. A single badge would hide which of them was observed, and
that matters most exactly when one is missing.

Each carries the two asymmetries in its own tests. A witness outage withholds
L3 without reducing what the vault signed, because event-time assurance is a
fact about the past that no later outage changes. And an anchor never
promotes anything -- the report says so out loud, so a reader does not infer
it did.

The nine-case table is enumerated in each language, which is the only way two
implementations of a rule this narrow can be shown to agree. Python and
TypeScript were checked against each other directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:45:18 +02:00
CodexandClaude Opus 5 c1481e17dd fix(sdk): TypeScript had the same zero-value bug, and Go did not
noble rejects multiply(0n) exactly as libsodium rejects a zero scalar, and
the TypeScript SDK read the refusal as an invalid opening the same way
Python did. Both now multiply a zero scalar to the identity by hand.

gtank/ristretto255 accepts it, so the Go module was correct all along. Three
implementations: two agreed with each other and both were wrong, and the one
that matched the Rust core stood alone. That is the argument for a shared
corpus rather than per-language tests -- two implementations agreeing is not
evidence.

The zero vector is now consumed by all three, so the coverage contract holds
every client to it: 27 of 27 in Python, Go and TypeScript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:10:15 +02:00
CodexandClaude Opus 5 7b34da7c78 feat(sdk): verify disclosures in Go and TypeScript against the same bytes
All three clients now verify a presentation the Local Vault really built through
the real edge core. The fixture is checked in rather than written to satisfy the
verifiers: three implementations agreeing with each other proves less than three
agreeing with the producer.

The fixture is generated once and not regenerated on every run — a disclosure
carries fresh randomizers and a fresh signature, so comparing regenerated bytes
would fail by design. Drift is caught the other way round: the Local Vault's own
verifier checks the checked-in fixture, so a format change makes the producer
reject its own past output. CI runs that.

`bytesForSubtle` and `hexToBytes` move from private to exported in the
TypeScript proofstream module rather than being duplicated. Two hex decoders
that could disagree is a worse outcome than one shared internal helper.

Drift testing found that **nothing tested inclusion at all**. Removing the
two-hop check left every disclosure test passing in all three languages: a
tampered value was caught by the commitment check, a tampered signature by the
signature check, but a leaf belonging to an entirely different capsule would
have been accepted. That is the one thing a disclosure is for. Each SDK now has
a test that corrupts a sibling in the subtree path and another in the top path,
leaving value and randomizer untouched so only the fold can catch it.

Corpus coverage 26/26 and 12/12 in all three languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:52:22 +02:00
CodexandClaude Opus 5 0b881e1a74 feat(sdk): verify Pedersen openings in all three clients, without imposing a curve library
The eight private-numeric vectors were a declared boundary: verifying them needs
ristretto255 scalar arithmetic no SDK carried. Adding one everywhere would have
cost something real — the Go and TypeScript SDKs have *zero* dependencies, which
is a property their consumers get for free today.

So it is optional in each, and the shape differs per ecosystem: a `attesto[zk]`
extra in Python, an optional peer dependency in TypeScript, and a separate
`go.attesto.eu/sdk/zk` module in Go. A consumer who never opens a private
numeric inherits nothing. `not_checked` now means "this installation did not
check" rather than "nobody can", which is a better answer to the same question.

Each was verified against a real Rust commitment before being chosen: pysodium
over libsodium, @noble/curves, and gtank/ristretto255 all reproduce the core's
bytes exactly. PyNaCl was tried first and ruled out — 1.6.2 exposes no
ristretto255 bindings at all.

This closes Pedersen opening verification, not range proofs. A range proof needs
a full bulletproofs implementation, not curve arithmetic, and stays the core's
job.

Two things the last vector forced:

* A value outside the descriptor's declared domain now returns invalid for the
  right reason. The commitment would fail to match anyway, but attributing that
  to the arithmetic when the real answer is "that value is outside the declared
  domain" blames the wrong layer. All three match the Rust core here.
* A skipped suite is a gate that proves nothing, so CI sets
  ATTESTO_REQUIRE_ZK_EXTRA and an environment that was supposed to install the
  dependency and did not now fails instead of reporting green over skips.

Corpus coverage is 25/25 and 12/12 in all three languages, with no exemption
left. The exemption mechanism is removed rather than emptied: reintroducing one
should be a visible decision, not a constant someone left lying around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:15:20 +02:00
CodexandClaude Opus 5 34b61b1c09 fix(security): close every fixable advisory and make the unfixable one unreachable
The dependency scan reported 14 high/critical findings across the backend and
the marketplace frontend. All of them are now closed, and the scan is green for
the first time.

**Bumped, with the suite as the check.** aiohttp 3.14.1 -> 3.14.3, pyasn1 0.6.3
-> 0.6.4, pydantic-settings 2.14.1 -> 2.15.0, and cryptography 48.0.1 -> 50.0.0.
That last one crosses two majors, which is why it was flagged as blast radius
rather than a routine bump; the full backend suite passes unchanged. nanoid and
postcss in the marketplace frontend are patched and the frontend still builds.

**ecdsa has no fix and never will.** CVE-2024-23342 is a Minerva timing attack on
P-256, and the project considers side channels out of scope. It arrives through
python-jose, and only signing, key generation and ECDH are affected —
verification is not. The backend signs tenant tokens with the symmetric
JWT_SECRET, so only HMAC families are coherent there anyway.

That was true by habit, not by construction: `jwt_algorithm` had no validation at
all, so JWT_ALGORITHM=ES256 would have signed through the vulnerable path with
nothing to say so. app/core/security.py now refuses any algorithm outside
HS256/HS384/HS512, on both the encode and decode paths, and
tests/test_jwt_algorithm_guard.py fails if that control is removed. `none` is
refused alongside ES*: an unsigned token is not a lesser problem than a badly
signed one.

The advisory is accepted by exact ID with that control named, using a mechanism
added here rather than by silencing the tool. A new advisory on ecdsa still
fails, and a package whose every finding is accepted stops being listed as
vulnerable so the field keeps meaning something.

Backend 1419 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:12:00 +02:00
CodexandClaude Opus 5 3aff9fa0fb feat(attesto3): pin the range statement and its width across all three SDKs
The statement is what gets folded into the proof transcript, and the width is
derived from its bounds. A client that ordered the fields differently or picked
a different width would produce proofs nobody else could verify — and the
symptom would read as a broken proof rather than a divergent implementation.
Both are pure arithmetic and canonical JSON, so every SDK can check them and now
does.

Each client gains `zk_range_width` and `validate_range_statement`. The field set
is exact rather than a minimum: an extra field would bind to nothing and a
missing one would change the challenges. A float bound is refused rather than
truncated, which is the encoding registry's whole purpose one layer up.

The width table is checked in as a vector and the Rust core asserts against that
file directly rather than against a second copy of the table. Changing one now
fails the other, which a duplicated constant would not have done.

Corpus coverage: 17/17 provenance and 12/12 zk-range in Python, Go and
TypeScript. The cross-SDK vector item is closed; Sprint 12 is down to three open
items, all of which need something local work cannot supply — other
architectures, a curve-library decision, and a UI.

Python 107, Go ok, TypeScript 119, Local Vault 375, edge 117.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 18:37:21 +02:00
CodexandClaude Opus 5 c31c1796ae feat(attesto3): let a verifier client say what it did not check
Sprint 12's own evidence named this the feature's largest risk: not a broken
proof, but a correct proof rendered as "AI generated: true". The SDKs had no
result surface at all — only cryptographic primitives — so nothing stopped a
consumer from reading a predicate result however it liked.

All three now carry `inspect_predicate_result`, and the rule that shapes it is
that a client without ristretto255 arithmetic cannot verify a range proof and
must say so. `verified_here.zk_predicate` is always `not_checked`; the issuer's
own verification block is carried separately under `reported_by_issuer`. An SDK
that merged the two would be passing the prover's word through as though it had
confirmed it, which is the failure the whole construction exists to prevent.
Inclusion, which is SHA-256, is reported as genuinely checked when the caller
checked it.

A result is refused outright if it omits one of the three non-claims or carries
a field a consumer could render as a verdict — at any nesting depth, since
`predicate.confidence` misleads exactly as well as a top-level one.

The corpus is generated by calling the real evaluator, so the fixture cannot
drift from the implementation, and a contract compares rather than regenerates.

Extending the coverage contract to a second corpus surfaced a third one:
`provenance-envelope-v0.1` matched nothing. It turned out to be guarded a
different but equally strict way — its own contract pins an explicit inventory —
so the contract now models both shapes. "Checked somewhere else" and "checked by
nobody" can no longer look the same, and a new corpus fails until one model or
the other covers it.

Python 100, Go ok, TypeScript 115, Local Vault 375.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 18:17:14 +02:00
CodexandClaude Opus 5 496d622671 feat(attesto3): make every SDK check every vector it is able to check
Twelve of twenty-five provenance vectors were consumed by no SDK. From outside
the repository that looked exactly like full coverage, which is the problem: a
corpus proves nothing about an implementation that never loads it.

Vectors now declare what they require. `sha256` vectors use SHA-256 and
canonical JSON, which all three SDKs have, so an unconsumed one is a gap and
fails a contract. `ristretto255` vectors need curve scalar arithmetic no SDK
carries; those are a declared boundary with a stated reason rather than a silent
skip, so the exemption cannot spread by habit.

Coverage went from 13/17 reachable in each SDK to 17/17. Closing the four gaps
surfaced a real verifier weakness: `capsule_root` receives digests, so by then a
role is no longer visible, and a tree carrying `evidence_root` twice with
`vault_identity_commitment` missing folds to a root all three SDKs accepted.
Each gains `ordered_top_leaf_digests`, which requires each of the six roles
exactly once, and the safe path is now the easy one.

Two findings of my own drift:

* The Go corpus-typing test accepted only `valid` and `invalid`, so it had been
  failing since the Sprint 1 recovery added vectors carrying `differs` and
  `rejected`. I updated Python's typing test then and not Go's, and no gate
  caught it because the SDK parity suites are not in the sprint gates. Fixed,
  and both Go and TypeScript now also require the capability declaration.
* TypeScript's strict indexing caught that a missing randomizer would have
  reached the hash as the string "undefined". Both halves are now checked.

Python 88, Go ok, TypeScript 107, Local Vault 375, edge 116, backend 1404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:36:13 +02:00
CodexandClaude Opus 5 f80b28c3b5 feat(attesto3): register the REVIEW-02 disclosure v2 and ZK range domains
Sprint 1 recovery, first item. §5.2 of REVIEW-02 adds three domains the registry
did not carry: attesto.disclosure.v2, attesto.zk.range.v1.statement and
attesto.zk.range.v1.transcript.

They are not in the attesto.provenance.v1. namespace, and that is deliberate:
disclosure v2 and the ZK range protocol are separate protocols with their own
versions, so a preimage space is named after the protocol that owns it rather
than the one it happens to travel with.

That namespace difference meant the parity contract could not see them at all —
its pattern matched attesto.provenance.v1.* only, so three new domains would have
been silently unguarded. The pattern now names each protocol explicitly rather
than loosening to a prefix wildcard: a looser first attempt also matched prose
that mentions a namespace without a terminal segment and reported it as an
unknown domain.

All four registry locations updated together with the golden vector, and all
three SDK parity suites plus the contract are green. The Go failure message was
also corrected: it printed "rust=21 go=21" while failing on a third hardcoded
expectation it never named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:01:24 +02:00
CodexandClaude Fable 5 9e6ae6277a feat(attesto3): Sprint 1 — pinned attesto-edge core + 3-language parity
Establishes the single normative cryptographic authority for the provenance
lane, and freezes the boundary and Merkle semantics before any ingestion path
exists to depend on them.

New crate edge/ (attesto-edge)
- domains.rs — the closed 18-domain v1 registry. Unknown domains are errors,
  never a fallback: a generic attesto.provenance.v1.commitment would let two
  unrelated objects share a preimage space, which is what domain separation
  exists to prevent.
- canonical.rs — conforming ATTESTO-CANONICAL-JSON-001, not a second
  serializer. Floats and integers past 2^53-1 are refused with their JSON path.
- commitment.rs — randomized, domain-separated commitments. Legacy Proofstream
  commitments stay deterministic; provenance values are low-entropy, so
  claim_type = "c2pa_manifest_valid" hashed deterministically is a dictionary
  lookup and a deterministic asset digest links a file across events. Debug for
  Randomizer prints <redacted>: it is C1 and Debug output reaches logs.
- merkle.rs — the two-level capsule forest. A claim leaf cannot verify against
  evidence_root on two independent grounds: subtrees fold under different node
  domains, and the top leaf binds leaf_role. Odd nodes are promoted, never
  duplicated, matching the rule inclusion.json already pins for Proofstream.
- boundary.rs — derives nothing. It shapes a request for attesto-nova, reusing
  the existing event-payload 16 KiB size class so Nova's closed
  boundary_max_len() allowlist needs no new entry. On-wire artifact is N10.R
  redacted.
- main.rs — NDJSON surface (handshake, canonicalize, commit, capsule-root,
  boundary-derive, self-test), the transport the backend already speaks.

Poseidon is deliberately absent. It stays in proofs/nova, reached through that
crate's public boundary API, so there remains exactly one Poseidon authority.
The only Nova change is making CIRCUIT_ID and PROVER_VERSION pub so the edge
handshake can report the prover it wraps; its 40 tests are unchanged.

Test-only randomizers are gated behind the `test-vectors` cargo feature and
compiled out of release builds. A caller who can choose the randomizer can make
production commitments deterministic — that is not a debug convenience, it is
the vulnerability. A release build refuses one and reports
accepts_caller_randomizers: false in its handshake.

Conformance
- golden-vectors/provenance-v0.1-dev/ — 14 Rust-authored vectors, 9 valid and 5
  invalid. CI regenerates them and requires git diff --exit-code, so the
  committed corpus cannot drift from what the normative core produces.
- Python (sdk/python/src/attesto/provenance.py, 19 tests) and Go
  (sdk/go/provenance.go, 14 tests) reproduce every valid vector and refuse every
  invalid one. Both reuse their existing canonical-JSON primitives rather than
  forking a second implementation.
- provenance_domain_registry_contract.py pins Rust = spec = Python = Go =
  vector, and that no registry declares the forbidden fallback. It reads each
  declaration block rather than whole files, so the negative test cases that
  must name the fallback do not trip it.

TypeScript parity is still owed and Sprint 1's DoD is not fully closed: the
sdk/typescript build break recorded in the Sprint 0 baseline makes its whole
suite unrunnable.

Also fixes a Sprint 0 guard found by the guard itself: the naming lint scanned
only tracked files, so new work read green until it was committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 13:58:00 +02:00
Codex 5030782a22 Harden CLI config persistence 2026-06-17 15:50:44 +02:00
Codex f0605f1c3f Harden CLI local vault spool durability 2026-06-17 15:32:41 +02:00
Codex 76349a6b55 Harden Go SDK head store persistence 2026-06-17 15:14:54 +02:00
Codex b4f47fb17a Fail closed on Go SDK idempotency entropy errors 2026-06-17 13:07:18 +02:00
Codex ecf8106c56 Return errors for Go Local Vault marshal failures 2026-06-17 12:50:59 +02:00
Codex 8d6d9bf9c2 fix: harden connector manifest validation 2026-06-16 23:40:33 +02:00
Codex 4a4b86ae10 chore: remove connector stub wording 2026-06-16 20:47:38 +02:00
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
CodexandClaude Fable 5 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
31 changed files with 5726 additions and 59 deletions
+50
View File
@@ -10,6 +10,16 @@ tooling, CI, evidence exporters, and operator automation. Do not embed Attesto A
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
uses only the Go standard library.
@@ -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
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
When events are committed to a Proofstream, payload and metadata numbers must
@@ -129,6 +142,43 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead
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
+428
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
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")
}
}
+522
View File
@@ -0,0 +1,522 @@
package attesto
// The bundle provenance tree (ADR-0014, Option C) and the frozen revocation
// rule an offline verifier applies through it.
//
// A verifier bundle over a provenance stream commits to the capsule roots it
// spans through one Merkle root. Everything here is a client of
// edge/src/bundle_tree.rs; the bundle-tree-* vectors pin the agreement. The
// revocation rule mirrors the platform's key_revocation.evaluate exactly, so
// the ingest path and an offline verifier reach the same verdict from the same
// facts.
import (
"crypto/subtle"
"encoding/json"
"fmt"
"sort"
"time"
)
const (
BundleTreeDomain = "attesto.provenance.v1.bundle_tree"
bundleTreeName = "bundle_provenance"
BundleInclusionKind = "bundle_provenance_inclusion"
bundleProvenanceRootKey = "provenance_root"
bundleProvenanceCountKey = "provenance_event_count"
bundleKeyLifecycleKey = "vault_key_lifecycle"
KeyStatusValid = "valid"
KeyStatusRevokedAtReceipt = "revoked_at_receipt"
KeyStatusUnknownInstallation = "unknown_installation"
KeyStatusNotEvaluated = "not_evaluated"
FlagSuspectBackdated = "suspect_backdated"
// RevocationReasonUnrecorded marks an instant migration reconstructed rather
// than measured. The verdict stands; the caller is told not to read the
// instant as measured.
RevocationReasonUnrecorded = "unrecorded"
InclusionValid = "VALID"
InclusionInvalid = "INVALID"
)
// BundleLeaf is one provenance event as the bundle tree commits to it. The
// installation, key, assurance and vault-claimed occurred_at are bound with the
// capsule root on purpose: revocation is applied to the installation that
// produced the capsule, and a leaf carrying only the root would let that
// installation be swapped under it.
type BundleLeaf struct {
SeqNo int64 `json:"seq_no"`
CapsuleRoot string `json:"capsule_root"`
InstallationID string `json:"installation_id"`
KeyID string `json:"key_id"`
VaultAssurance string `json:"vault_assurance"`
OccurredAt string `json:"occurred_at"`
}
// BundleProvenanceTree is the committed tree plus what a prover needs.
type BundleProvenanceTree struct {
LeafCount int
MerkleRoot string
ProvenanceRoot string
OrderedLeaves []BundleLeaf
OrderedLeafDigests []string
}
// BundleInclusionProof is one leaf, proven to the typed provenance root.
type BundleInclusionProof struct {
Leaf BundleLeaf `json:"leaf"`
LeafCount int `json:"leaf_count"`
Steps []ProvenanceProofStep `json:"steps"`
ProvenanceRoot string `json:"provenance_root"`
}
func bundleLeafValue(leaf BundleLeaf) (map[string]any, error) {
if leaf.SeqNo < 0 {
return nil, fmt.Errorf("bundle leaf seq_no must be a non-negative integer")
}
if leaf.InstallationID == "" || leaf.KeyID == "" || leaf.OccurredAt == "" {
return nil, fmt.Errorf("bundle leaf installation_id, key_id and occurred_at must be non-empty")
}
known := false
for _, level := range VaultAssuranceLevels {
if level == leaf.VaultAssurance {
known = true
break
}
}
if !known {
// L3 included: it is verifier-derived and has no on-wire form, so a
// leaf claiming it is malformed rather than merely invalid.
return nil, fmt.Errorf("bundle leaf vault_assurance must be one of %v", VaultAssuranceLevels)
}
if err := assertProvenanceDigest("leaf.capsule_root", leaf.CapsuleRoot); err != nil {
return nil, err
}
return map[string]any{
"kind": "leaf",
"seq_no": leaf.SeqNo,
"capsule_root": leaf.CapsuleRoot,
"installation_id": leaf.InstallationID,
"key_id": leaf.KeyID,
"vault_assurance": leaf.VaultAssurance,
"occurred_at": leaf.OccurredAt,
}, nil
}
// BundleProvenanceLeaf hashes one provenance event as the bundle tree commits
// to it.
func BundleProvenanceLeaf(leaf BundleLeaf) (string, error) {
value, err := bundleLeafValue(leaf)
if err != nil {
return "", err
}
if err := AssertCommitmentSafeNumbers(value, "$"); err != nil {
return "", err
}
return DomainHashHex(BundleTreeDomain, value)
}
func bundleTypedRoot(leafCount int, merkleRoot string) (string, error) {
return DomainHashHex(BundleTreeDomain, map[string]any{
"kind": "root",
"tree": bundleTreeName,
"leaf_count": leafCount,
"merkle_root": merkleRoot,
})
}
// BundleProvenanceRoot folds the events a bundle spans, in seq_no order, into
// its typed root. A repeated seq_no is refused: one event cannot be two leaves.
func BundleProvenanceRoot(leaves []BundleLeaf) (*BundleProvenanceTree, error) {
if len(leaves) == 0 {
return nil, fmt.Errorf("cannot build an empty %s tree", bundleTreeName)
}
ordered := append([]BundleLeaf(nil), leaves...)
sort.SliceStable(ordered, func(left, right int) bool {
return ordered[left].SeqNo < ordered[right].SeqNo
})
digests := make([]string, 0, len(ordered))
for index, leaf := range ordered {
if index > 0 && ordered[index-1].SeqNo == leaf.SeqNo {
return nil, fmt.Errorf("duplicate leaf id %d", leaf.SeqNo)
}
digest, err := BundleProvenanceLeaf(leaf)
if err != nil {
return nil, err
}
digests = append(digests, digest)
}
merkleRoot, err := provenanceFold(BundleTreeDomain, digests)
if err != nil {
return nil, err
}
root, err := bundleTypedRoot(len(digests), merkleRoot)
if err != nil {
return nil, err
}
return &BundleProvenanceTree{
LeafCount: len(digests),
MerkleRoot: merkleRoot,
ProvenanceRoot: root,
OrderedLeaves: ordered,
OrderedLeafDigests: digests,
}, nil
}
func collectProvenanceProof(domain string, level []string, index int) ([]ProvenanceProofStep, error) {
steps := []ProvenanceProofStep{}
current := append([]string(nil), level...)
for len(current) > 1 {
next := make([]string, 0, (len(current)+1)/2)
nextIndex := index
for cursor := 0; cursor < len(current); cursor += 2 {
if cursor+1 >= len(current) {
// Promoted node: it rises with no sibling, so no proof step.
if cursor == index {
nextIndex = len(next)
}
next = append(next, current[cursor])
continue
}
if cursor == index {
steps = append(steps, ProvenanceProofStep{Side: "right", Sibling: current[cursor+1]})
nextIndex = len(next)
} else if cursor+1 == index {
steps = append(steps, ProvenanceProofStep{Side: "left", Sibling: current[cursor]})
nextIndex = len(next)
}
node, err := provenanceNode(domain, current[cursor], current[cursor+1])
if err != nil {
return nil, err
}
next = append(next, node)
}
current = next
index = nextIndex
}
return steps, nil
}
// BundleProvenanceProof proves one event under the bundle's provenance root.
func BundleProvenanceProof(leaves []BundleLeaf, seqNo int64) (*BundleInclusionProof, error) {
tree, err := BundleProvenanceRoot(leaves)
if err != nil {
return nil, err
}
for index, leaf := range tree.OrderedLeaves {
if leaf.SeqNo != seqNo {
continue
}
steps, err := collectProvenanceProof(BundleTreeDomain, tree.OrderedLeafDigests, index)
if err != nil {
return nil, err
}
return &BundleInclusionProof{
Leaf: leaf,
LeafCount: tree.LeafCount,
Steps: steps,
ProvenanceRoot: tree.ProvenanceRoot,
}, nil
}
return nil, fmt.Errorf("unknown seq_no: %d", seqNo)
}
// VerifyBundleProvenanceInclusion checks that leaf sits under provenanceRoot.
//
// The leaf is re-hashed from its fields, never taken as a digest, so a proof
// cannot substitute one between leaf and root. It returns (false, nil) for a
// cryptographic failure and an error for a malformed object.
func VerifyBundleProvenanceInclusion(provenanceRoot string, leaf BundleLeaf, steps []ProvenanceProofStep, leafCount int) (bool, error) {
if err := assertProvenanceDigest("provenance_root", provenanceRoot); err != nil {
return false, err
}
if leafCount < 1 {
return false, fmt.Errorf("leaf_count must be a positive integer")
}
digest, err := BundleProvenanceLeaf(leaf)
if err != nil {
return false, err
}
merkleRoot, err := replayProvenanceProof(BundleTreeDomain, digest, steps)
if err != nil {
return false, err
}
derived, err := bundleTypedRoot(leafCount, merkleRoot)
if err != nil {
return false, err
}
return subtle.ConstantTimeCompare([]byte(derived), []byte(provenanceRoot)) == 1, nil
}
// KeyRevocationVerdict is what the key lifecycle says about one event, and why.
type KeyRevocationVerdict struct {
Status string `json:"status"`
Flags []string `json:"flags"`
RevokedAt *time.Time `json:"revoked_at"`
Reason string `json:"reason"`
// True when the effective instant was reconstructed by migration. The
// verdict still stands; the caller is told not to read it as measured.
InstantReconstructed bool `json:"instant_reconstructed"`
}
// Accepted reports whether the key was live at receipt. It says nothing about
// the signature, which is checked separately.
func (v KeyRevocationVerdict) Accepted() bool { return v.Status == KeyStatusValid }
// EvaluateKeyRevocation decides whether a key was live when the platform
// received the event.
//
// Revocation is evaluated against the platform receipt time, never the
// vault-claimed occurred_at: a holder controls what it claims, not when the
// platform received it. The boundary is inclusive — an event receipted exactly
// at the revocation instant is revoked, because the alternative gives a
// compromised key one more accepted event. A claim that predates revocation
// while its receipt does not is flagged suspect_backdated in addition to being
// revoked, not instead of.
//
// A nil revokedAt means the key was never revoked, which is a different thing
// from a key revoked in the future and must not be conflated: the second is a
// scheduled retirement and is still evidence.
func EvaluateKeyRevocation(revokedAt *time.Time, receiptTime time.Time, claimedOccurredAt *time.Time, reason string) KeyRevocationVerdict {
if revokedAt == nil {
return KeyRevocationVerdict{Status: KeyStatusValid, Flags: []string{}}
}
effective := revokedAt.UTC()
received := receiptTime.UTC()
reconstructed := reason == RevocationReasonUnrecorded
if received.Before(effective) {
return KeyRevocationVerdict{
Status: KeyStatusValid,
Flags: []string{},
RevokedAt: &effective,
Reason: reason,
InstantReconstructed: reconstructed,
}
}
flags := []string{KeyStatusRevokedAtReceipt}
if claimedOccurredAt != nil && claimedOccurredAt.UTC().Before(effective) {
flags = append(flags, FlagSuspectBackdated)
}
return KeyRevocationVerdict{
Status: KeyStatusRevokedAtReceipt,
Flags: flags,
RevokedAt: &effective,
Reason: reason,
InstantReconstructed: reconstructed,
}
}
// BundleProvenanceNotClaimed states what a verified inclusion does not prove.
var BundleProvenanceNotClaimed = []map[string]string{
{
"id": "bundle_asserts_capsule_existence_not_contents",
"statement": "The provenance_root proves this capsule root was among the events " +
"the bundle spans. It says nothing about what the capsule contains; the " +
"platform never opens one.",
},
{
"id": "revocation_evaluated_against_platform_receipt_time_not_vault_occurred_at",
"statement": "Key revocation is evaluated against the platform receipt time of " +
"this seq_no. The vault-claimed occurred_at is reported, never trusted to " +
"escape revocation.",
},
{
"id": "key_lifecycle_as_of_bundle_build",
"statement": "vault_key_lifecycle is the installation's lifecycle when the bundle " +
"was built. A revocation recorded later is not in this bundle.",
},
}
// BundleProvenanceReport is what one inclusion established against its bundle,
// fact by fact. Inclusion and KeyStatus are separate on purpose: a capsule root
// can be provably under the bundle while its key was revoked before receipt,
// and collapsing the two would lose exactly the distinction an investigator
// needs. Ok is true only when the bundle hash holds, the inclusion verifies and
// the key was live at receipt.
type BundleProvenanceReport struct {
Ok bool `json:"ok"`
Inclusion string `json:"inclusion"`
KeyStatus string `json:"key_status"`
Flags []string `json:"flags"`
SeqNo *int64 `json:"seq_no"`
InstallationID string `json:"installation_id"`
CapsuleRoot string `json:"capsule_root"`
ReceiptTime string `json:"receipt_time"`
RevokedAt string `json:"revoked_at"`
Problems []string `json:"problems"`
NotClaimed []map[string]string `json:"not_claimed"`
}
func parseRFC3339(raw string) (*time.Time, bool) {
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return nil, false
}
return &parsed, true
}
func receiptIssuedAt(bundle map[string]any, seqNo int64) string {
for _, raw := range asSlice(bundle["receipts"]) {
item, ok := raw.(map[string]any)
if !ok {
continue
}
itemSeq, ok := item["seq_no"].(float64)
if !ok || int64(itemSeq) != seqNo {
continue
}
receipt, _ := item["receipt"].(map[string]any)
payload, _ := receipt["payload"].(map[string]any)
return toString(payload["issued_at"])
}
return ""
}
// VerifyBundleProvenance verifies one capsule's inclusion in a verifier bundle,
// offline. It needs nothing but the bundle and the inclusion object: it
// recomputes the bundle hash so the provenance root and key lifecycle are
// authenticated, checks the leaf under the root, takes the receipt time for the
// leaf's seq_no from the bundle's own receipts, and applies the frozen
// revocation rule to the installation the leaf names. Every problem is
// collected rather than returned on the first one.
func VerifyBundleProvenance(bundle map[string]any, inclusion map[string]any) BundleProvenanceReport {
problems := []string{}
payload, ok := bundle["payload"].(map[string]any)
if !ok {
payload = map[string]any{}
problems = append(problems, "invalid bundle object")
}
bundleHash := toString(bundle["bundle_hash"])
if len(payload) > 0 {
derived, err := DomainHashHex(ProofstreamDomains["bundle"], payload)
if err != nil || derived != bundleHash {
problems = append(problems, "bundle_hash mismatch")
}
}
var leaf BundleLeaf
var seqNo *int64
if rawLeaf, ok := inclusion["leaf"].(map[string]any); ok {
if encoded, err := json.Marshal(rawLeaf); err == nil {
_ = json.Unmarshal(encoded, &leaf)
}
if value, ok := rawLeaf["seq_no"].(float64); ok {
n := int64(value)
seqNo = &n
}
}
if toString(inclusion["kind"]) != BundleInclusionKind {
problems = append(problems, "inclusion is not a bundle_provenance_inclusion object")
}
if toString(inclusion["bundle_hash"]) != bundleHash {
problems = append(problems, "inclusion is for a different bundle")
}
included := InclusionInvalid
declaredRoot, hasRoot := payload[bundleProvenanceRootKey].(string)
if !hasRoot {
problems = append(problems, "bundle carries no provenance_root")
} else {
if toString(inclusion["provenance_root"]) != declaredRoot {
problems = append(problems, "inclusion names a different provenance_root")
}
leafCount, _ := inclusion["leaf_count"].(float64)
declaredCount, _ := payload[bundleProvenanceCountKey].(float64)
if leafCount != declaredCount {
problems = append(problems, "inclusion leaf_count does not match provenance_event_count")
}
var steps []ProvenanceProofStep
if encoded, err := json.Marshal(inclusion["steps"]); err == nil {
_ = json.Unmarshal(encoded, &steps)
}
verified, err := VerifyBundleProvenanceInclusion(declaredRoot, leaf, steps, int(leafCount))
switch {
case err != nil:
problems = append(problems, "inclusion is malformed: "+err.Error())
case verified:
included = InclusionValid
default:
problems = append(problems, "leaf is not included under the bundle's provenance_root")
}
}
receiptTime := ""
if seqNo != nil {
receiptTime = receiptIssuedAt(bundle, *seqNo)
}
if receiptTime == "" {
problems = append(problems, fmt.Sprintf("bundle carries no receipt for seq_no %v", formatSeqNo(seqNo)))
}
var entry map[string]any
for _, raw := range asSlice(payload[bundleKeyLifecycleKey]) {
candidate, ok := raw.(map[string]any)
if ok && toString(candidate["installation_id"]) == leaf.InstallationID && leaf.InstallationID != "" {
entry = candidate
break
}
}
keyStatus := KeyStatusUnknownInstallation
flags := []string{}
revokedAt := ""
switch {
case entry == nil:
problems = append(problems, fmt.Sprintf("installation %s is not in the bundle's key lifecycle", leaf.InstallationID))
case receiptTime == "":
keyStatus = KeyStatusNotEvaluated
default:
if toString(entry["key_id"]) != leaf.KeyID {
problems = append(problems, "key lifecycle key_id does not match the leaf")
}
received, ok := parseRFC3339(receiptTime)
if !ok {
keyStatus = KeyStatusNotEvaluated
problems = append(problems, "receipt issued_at is not an RFC 3339 instant")
break
}
var effective *time.Time
if raw := toString(entry["revoked_at"]); raw != "" {
parsed, ok := parseRFC3339(raw)
if !ok {
keyStatus = KeyStatusNotEvaluated
problems = append(problems, "key lifecycle is malformed: revoked_at is not an RFC 3339 instant")
break
}
effective = parsed
revokedAt = raw
}
claimed, _ := parseRFC3339(leaf.OccurredAt)
verdict := EvaluateKeyRevocation(effective, *received, claimed, toString(entry["revocation_reason"]))
keyStatus = verdict.Status
flags = verdict.Flags
if !verdict.Accepted() {
problems = append(problems, fmt.Sprintf(
"installation %s was revoked before seq_no %s was received", leaf.InstallationID, formatSeqNo(seqNo),
))
}
}
return BundleProvenanceReport{
Ok: len(problems) == 0 && included == InclusionValid && keyStatus == KeyStatusValid,
Inclusion: included,
KeyStatus: keyStatus,
Flags: flags,
SeqNo: seqNo,
InstallationID: leaf.InstallationID,
CapsuleRoot: leaf.CapsuleRoot,
ReceiptTime: receiptTime,
RevokedAt: revokedAt,
Problems: problems,
NotClaimed: BundleProvenanceNotClaimed,
}
}
func formatSeqNo(seqNo *int64) string {
if seqNo == nil {
return "<none>"
}
return fmt.Sprintf("%d", *seqNo)
}
+268
View File
@@ -0,0 +1,268 @@
package attesto
// ADR-0014 — a verifier bundle's provenance root, checked offline.
//
// The Merkle rules are pinned by bundle-tree-* in the golden corpus; these
// tests cover what sits on top of them: the bundle hash authenticating the root
// and the key lifecycle, the receipt time coming from the bundle's own receipts,
// and the frozen revocation rule applied to the installation the leaf names.
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
)
const testRevokedAt = "2026-08-18T12:40:00.000Z"
func testBundleLeaf(seqNo int64, installation, occurredAt string) BundleLeaf {
if occurredAt == "" {
occurredAt = fmt.Sprintf("2026-08-18T12:3%d:00.000Z", seqNo)
}
return BundleLeaf{
SeqNo: seqNo,
CapsuleRoot: strings.Repeat(fmt.Sprintf("%02x", seqNo), 32),
InstallationID: installation,
KeyID: "key-" + installation,
VaultAssurance: "L1",
OccurredAt: occurredAt,
}
}
func testLifecycle(installation, revokedAt, reason string) map[string]any {
status := "active"
var revoked any
if revokedAt != "" {
status = "revoked"
revoked = revokedAt
}
var reasonValue any
if reason != "" {
reasonValue = reason
}
return map[string]any{
"installation_id": installation,
"key_id": "key-" + installation,
"public_key_hex": strings.Repeat("ab", 32),
"status": status,
"revoked_at": revoked,
"revocation_reason": reasonValue,
"replaced_by_installation_id": nil,
"revocation_instant_reconstructed": reason == "unrecorded",
}
}
// roundTrip turns typed values into the map[string]any shape a JSON bundle has.
func roundTrip(t *testing.T, value any) map[string]any {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(encoded, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
func testBundle(t *testing.T, leaves []BundleLeaf, lifecycle []map[string]any, issued map[int64]string) map[string]any {
t.Helper()
tree, err := BundleProvenanceRoot(leaves)
if err != nil {
t.Fatalf("root: %v", err)
}
payload := map[string]any{
"kind": "verifier-bundle",
"event_count": len(leaves),
"provenance_root": tree.ProvenanceRoot,
"provenance_event_count": tree.LeafCount,
"vault_key_lifecycle": lifecycle,
}
hash, err := DomainHashHex(ProofstreamDomains["bundle"], payload)
if err != nil {
t.Fatalf("hash: %v", err)
}
receipts := []map[string]any{}
for seqNo, moment := range issued {
receipts = append(receipts, map[string]any{
"seq_no": seqNo,
"receipt": map[string]any{"payload": map[string]any{"seq_no": seqNo, "issued_at": moment}},
})
}
return roundTrip(t, map[string]any{"payload": payload, "bundle_hash": hash, "receipts": receipts})
}
func testInclusion(t *testing.T, bundle map[string]any, leaves []BundleLeaf, seqNo int64) map[string]any {
t.Helper()
proof, err := BundleProvenanceProof(leaves, seqNo)
if err != nil {
t.Fatalf("prove: %v", err)
}
inclusion := roundTrip(t, proof)
inclusion["kind"] = BundleInclusionKind
inclusion["protocol"] = "ATTESTO-PROOFSTREAM-001"
inclusion["protocol_version"] = "0.1-alpha"
inclusion["bundle_hash"] = bundle["bundle_hash"]
return inclusion
}
var (
testLeaves = []BundleLeaf{testBundleLeaf(1, "lvi_alpha", ""), testBundleLeaf(2, "lvi_beta", ""), testBundleLeaf(3, "lvi_alpha", "")}
testIssued = map[int64]string{1: "2026-08-18T12:31:05.000Z", 2: "2026-08-18T12:32:05.000Z", 3: "2026-08-18T12:33:05.000Z"}
)
func liveLifecycle() []map[string]any {
return []map[string]any{testLifecycle("lvi_alpha", "", ""), testLifecycle("lvi_beta", "", "")}
}
func TestBundleProvenanceValidInclusionUnderLiveKeyIsAccepted(t *testing.T) {
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2))
if !report.Ok {
t.Fatalf("expected ok: %v", report.Problems)
}
if report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusValid || len(report.Flags) != 0 {
t.Fatalf("unexpected report: %+v", report)
}
if *report.SeqNo != 2 || report.InstallationID != "lvi_beta" || report.ReceiptTime != testIssued[2] {
t.Fatalf("unexpected facts: %+v", report)
}
ids := map[string]bool{}
for _, claim := range report.NotClaimed {
ids[claim["id"]] = true
}
if !ids["bundle_asserts_capsule_existence_not_contents"] ||
!ids["revocation_evaluated_against_platform_receipt_time_not_vault_occurred_at"] {
t.Fatalf("non-claims missing: %v", report.NotClaimed)
}
}
func TestBundleProvenanceForeignLeafIsNotIncluded(t *testing.T) {
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
inclusion := testInclusion(t, bundle, testLeaves, 1)
inclusion["leaf"].(map[string]any)["capsule_root"] = strings.Repeat("ee", 32)
report := VerifyBundleProvenance(bundle, inclusion)
if report.Ok || report.Inclusion != InclusionInvalid {
t.Fatalf("foreign leaf must not be included: %+v", report)
}
// The key verdict is still reported: the two facts are independent.
if report.KeyStatus != KeyStatusValid {
t.Fatalf("key status must still be evaluated: %+v", report)
}
}
func TestBundleProvenanceWrongRootIsRefused(t *testing.T) {
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
inclusion := testInclusion(t, bundle, testLeaves, 1)
inclusion["provenance_root"] = strings.Repeat("ab", 32)
report := VerifyBundleProvenance(bundle, inclusion)
if report.Ok || !strings.Contains(strings.Join(report.Problems, ";"), "different provenance_root") {
t.Fatalf("wrong root must be refused: %+v", report)
}
}
func TestBundleProvenanceKeyRevokedBeforeReceipt(t *testing.T) {
leaves := []BundleLeaf{testBundleLeaf(1, "lvi_alpha", ""), testBundleLeaf(2, "lvi_beta", ""), testBundleLeaf(3, "lvi_alpha", "2026-08-18T12:45:00.000Z")}
issued := map[int64]string{1: testIssued[1], 2: testIssued[2], 3: "2026-08-18T12:45:05.000Z"}
lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, "key_compromise"), testLifecycle("lvi_beta", "", "")}
bundle := testBundle(t, leaves, lifecycle, issued)
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, leaves, 3))
if report.Ok || report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusRevokedAtReceipt {
t.Fatalf("expected revoked_at_receipt: %+v", report)
}
if strings.Join(report.Flags, ",") != "revoked_at_receipt" || report.RevokedAt != testRevokedAt {
t.Fatalf("unexpected flags: %+v", report)
}
// The same installation's earlier event, received before revocation, stands.
earlier := VerifyBundleProvenance(bundle, testInclusion(t, bundle, leaves, 1))
if !earlier.Ok || earlier.KeyStatus != KeyStatusValid {
t.Fatalf("earlier event must stand: %+v", earlier)
}
}
func TestBundleProvenanceBackdatedClaimIsFlagged(t *testing.T) {
issued := map[int64]string{1: testIssued[1], 2: testIssued[2], 3: "2026-08-18T12:45:05.000Z"}
lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, ""), testLifecycle("lvi_beta", "", "")}
bundle := testBundle(t, testLeaves, lifecycle, issued)
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 3))
if report.KeyStatus != KeyStatusRevokedAtReceipt || strings.Join(report.Flags, ",") != "revoked_at_receipt,suspect_backdated" {
t.Fatalf("expected suspect_backdated: %+v", report)
}
}
func TestBundleProvenanceUnknownInstallation(t *testing.T) {
bundle := testBundle(t, testLeaves, []map[string]any{testLifecycle("lvi_alpha", "", "")}, testIssued)
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2))
if report.Ok || report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusUnknownInstallation {
t.Fatalf("expected unknown_installation: %+v", report)
}
}
func TestBundleProvenanceTamperedLifecycleBreaksTheBundleHash(t *testing.T) {
lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, ""), testLifecycle("lvi_beta", "", "")}
bundle := testBundle(t, testLeaves, lifecycle, testIssued)
inclusion := testInclusion(t, bundle, testLeaves, 1)
entries := bundle["payload"].(map[string]any)["vault_key_lifecycle"].([]any)
entries[0].(map[string]any)["revoked_at"] = nil
report := VerifyBundleProvenance(bundle, inclusion)
if report.Ok || !strings.Contains(strings.Join(report.Problems, ";"), "bundle_hash mismatch") {
t.Fatalf("a tampered lifecycle must break the bundle hash: %+v", report)
}
}
func TestBundleProvenanceMissingReceiptLeavesTheKeyUnevaluated(t *testing.T) {
bundle := testBundle(t, testLeaves, liveLifecycle(), map[int64]string{1: testIssued[1]})
report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2))
if report.Ok || report.KeyStatus != KeyStatusNotEvaluated {
t.Fatalf("expected not_evaluated: %+v", report)
}
}
func TestBundleProvenanceInclusionForAnotherBundleIsRefused(t *testing.T) {
bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued)
inclusion := testInclusion(t, bundle, testLeaves, 1)
inclusion["bundle_hash"] = strings.Repeat("00", 32)
report := VerifyBundleProvenance(bundle, inclusion)
if !strings.Contains(strings.Join(report.Problems, ";"), "inclusion is for a different bundle") {
t.Fatalf("expected refusal: %+v", report)
}
}
func TestKeyRevocationMirrorsThePlatformRule(t *testing.T) {
revoked := time.Date(2026, 8, 18, 12, 40, 0, 0, time.UTC)
never := EvaluateKeyRevocation(nil, revoked, nil, "")
if never.Status != KeyStatusValid || len(never.Flags) != 0 || never.RevokedAt != nil {
t.Fatalf("never revoked must be valid: %+v", never)
}
before := EvaluateKeyRevocation(&revoked, revoked.Add(-time.Millisecond), nil, "")
if !before.Accepted() || !before.RevokedAt.Equal(revoked) {
t.Fatalf("received before revocation must be valid: %+v", before)
}
// Inclusive boundary: exactly at the instant is revoked.
at := EvaluateKeyRevocation(&revoked, revoked, nil, "")
if at.Status != KeyStatusRevokedAtReceipt || strings.Join(at.Flags, ",") != "revoked_at_receipt" {
t.Fatalf("at the instant must be revoked: %+v", at)
}
claimedBefore := revoked.Add(-time.Minute)
backdated := EvaluateKeyRevocation(&revoked, revoked.Add(time.Minute), &claimedBefore, "")
if strings.Join(backdated.Flags, ",") != "revoked_at_receipt,suspect_backdated" {
t.Fatalf("backdated claim must be flagged: %+v", backdated)
}
claimedAfter := revoked.Add(time.Minute)
honest := EvaluateKeyRevocation(&revoked, revoked.Add(time.Minute), &claimedAfter, "")
if strings.Join(honest.Flags, ",") != "revoked_at_receipt" {
t.Fatalf("honest claim must not be flagged: %+v", honest)
}
reconstructed := EvaluateKeyRevocation(&revoked, revoked.Add(-time.Hour), nil, "unrecorded")
if !reconstructed.Accepted() || !reconstructed.InstantReconstructed {
t.Fatalf("unrecorded reason must be reported: %+v", reconstructed)
}
if EvaluateKeyRevocation(&revoked, revoked.Add(-time.Hour), nil, "rotation").InstantReconstructed {
t.Fatalf("a measured instant must not be reported as reconstructed")
}
}
+44 -31
View File
@@ -29,6 +29,7 @@ type Client struct {
type Option func(*Client) error
var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`)
var secureRandomRead = rand.Read
func NewClient(apiKey string, opts ...Option) (*Client, error) {
if !apiKeyPattern.MatchString(apiKey) {
@@ -129,7 +130,7 @@ func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, opti
if input.Metadata == nil {
input.Metadata = M{}
}
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/streams", nil, input, &out, options)
return &out, err
}
@@ -184,7 +185,7 @@ func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput
}
}
var out EventReceipt
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
if err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, &out, options); err != nil {
return nil, err
}
if err := c.trackHead(out); err != nil {
@@ -223,7 +224,7 @@ func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventI
}
body := M{"events": events}
var out EventBatchResponse
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
if err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, &out, options); err != nil {
return nil, err
}
for _, receipt := range out.Receipts {
@@ -270,19 +271,19 @@ func (c *Client) GetIVCEpoch(ctx context.Context, ivcEpochID string) (M, error)
func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
var out VerifierBundle
err := c.requestJSON(ctx, http.MethodPost, "/v2/audit/packs", nil, body, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/audit/packs", nil, body, &out, options)
return &out, err
}
func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
var out VerifyReport
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, &out, options)
return &out, err
}
func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
var out VerifyReport
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/verify", nil, input, &out, options)
return &out, err
}
@@ -313,7 +314,7 @@ func (c *Client) ListTenantIVCEpochs(ctx context.Context, streamID string, limit
func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
var out VerifierBundle
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, &out, options)
return &out, err
}
@@ -323,7 +324,7 @@ func (c *Client) ListSignedWebhookConnectors(ctx context.Context, limit, offset
func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, &out, options)
return &out, err
}
@@ -337,13 +338,13 @@ func (c *Client) ListS3ObjectConnectors(ctx context.Context, limit, offset int)
func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, &out, options)
return &out, err
}
func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) {
var out EventReceipt
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, &out, options)
return &out, err
}
@@ -357,7 +358,7 @@ func (c *Client) ListRepositoryWebhookConnectors(ctx context.Context, limit, off
func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, &out, options)
return &out, err
}
@@ -421,7 +422,7 @@ func (c *Client) ListLocalVaultInstallations(ctx context.Context, limit, offset
func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) {
var out LocalVaultInstallation
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, idempotency(options), &out)
err := c.requestJSONIdempotent(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, &out, options)
return &out, err
}
@@ -431,22 +432,26 @@ func (c *Client) RevokeLocalVaultInstallation(ctx context.Context, installationI
func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) {
body := M{"envelope": envelope, "payload": payload}
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal local vault event: %w", err)
}
headers := map[string]string{
"X-Attesto-Local-Vault-Envelope-Hash": envelopeHash,
"X-Attesto-Local-Vault-Signature": signatureHex,
"X-Attesto-Local-Vault-Public-Key": publicKeyHex,
}
var out EventReceipt
err := c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, mustJSON(body), headers, "", &out)
err = c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, raw, headers, "", &out)
return &out, err
}
func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, idempotency(options))
return c.postObjectIdempotent(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, options)
}
func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options))
return c.postObjectIdempotent(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, options)
}
func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error) {
@@ -454,13 +459,13 @@ func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error)
}
func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v1/marketplace/publisher/assets", M{
return c.postObjectIdempotent(ctx, "/v1/marketplace/publisher/assets", M{
"manifest": input.Manifest,
"sourceRef": input.SourceRef,
"visibility": input.Visibility,
"pricingModel": input.PricingModel,
"priceCents": input.PriceCents,
}, idempotency(options))
}, options)
}
func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) {
@@ -474,7 +479,7 @@ func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string)
}
func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, idempotency(options))
return c.postObjectIdempotent(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, options)
}
func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) {
@@ -489,6 +494,14 @@ func (c *Client) postObject(ctx context.Context, path string, body M, idempotenc
return out, err
}
func (c *Client) postObjectIdempotent(ctx context.Context, path string, body M, options []RequestOptions) (M, error) {
idempotencyKey, err := idempotency(options)
if err != nil {
return nil, err
}
return c.postObject(ctx, path, body, idempotencyKey)
}
func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) {
values := url.Values{}
setPaging(values, limit, offset)
@@ -521,6 +534,14 @@ func (c *Client) requestJSON(ctx context.Context, method, path string, values ur
return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out)
}
func (c *Client) requestJSONIdempotent(ctx context.Context, method, path string, values url.Values, body any, out any, options []RequestOptions) error {
idempotencyKey, err := idempotency(options)
if err != nil {
return err
}
return c.requestJSON(ctx, method, path, values, body, idempotencyKey, out)
}
func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error {
if values != nil && len(values) > 0 {
path += "?" + values.Encode()
@@ -626,15 +647,15 @@ func skipPreflight(options []RequestOptions) bool {
return len(options) > 0 && options[0].SkipPreflight
}
func idempotency(options []RequestOptions) string {
func idempotency(options []RequestOptions) (string, error) {
if len(options) > 0 && options[0].IdempotencyKey != "" {
return options[0].IdempotencyKey
return options[0].IdempotencyKey, nil
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
if _, err := secureRandomRead(raw[:]); err != nil {
return "", fmt.Errorf("generate idempotency key: %w", err)
}
return hex.EncodeToString(raw[:])
return hex.EncodeToString(raw[:]), nil
}
func setPaging(values url.Values, limit, offset int) {
@@ -650,14 +671,6 @@ func sleep(attempt int) {
time.Sleep(time.Duration(100*attempt) * time.Millisecond)
}
func mustJSON(value any) []byte {
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func sanitizeMessage(message string) string {
for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} {
if strings.Contains(message, marker) {
+95
View File
@@ -3,6 +3,7 @@ package attesto
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -105,3 +106,97 @@ func TestBearerClientCanCallTenantEndpoints(t *testing.T) {
t.Fatalf("unexpected streams: %#v", streams)
}
}
func TestRelayLocalVaultEventReturnsMarshalError(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
t.Fatalf("request should not be sent when local vault event body cannot be marshaled")
}))
defer server.Close()
client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1))
if err != nil {
t.Fatalf("client: %v", err)
}
_, err = client.RelayLocalVaultEvent(
context.Background(),
"lv_123",
M{"source": "local-vault"},
M{"bad": func() {}},
strings.Repeat("a", 64),
strings.Repeat("b", 128),
strings.Repeat("c", 64),
)
if err == nil || !strings.Contains(err.Error(), "marshal local vault event") {
t.Fatalf("expected marshal error, got %v", err)
}
if called {
t.Fatalf("request was sent after marshal failure")
}
}
func TestGeneratedIdempotencyKeyFailsClosedOnEntropyError(t *testing.T) {
originalRead := secureRandomRead
secureRandomRead = func([]byte) (int, error) {
return 0, errors.New("entropy unavailable")
}
t.Cleanup(func() { secureRandomRead = originalRead })
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
t.Fatalf("request should not be sent when idempotency key generation fails")
}))
defer server.Close()
client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1))
if err != nil {
t.Fatalf("client: %v", err)
}
_, err = client.CreateStream(context.Background(), StreamCreateInput{UseCase: "ai-governance", PolicyID: "policy-main"})
if err == nil || !strings.Contains(err.Error(), "generate idempotency key") {
t.Fatalf("expected idempotency entropy error, got %v", err)
}
if called {
t.Fatalf("request was sent after idempotency generation failure")
}
}
func TestExplicitIdempotencyKeyBypassesEntropyGeneration(t *testing.T) {
originalRead := secureRandomRead
secureRandomRead = func([]byte) (int, error) {
return 0, errors.New("entropy unavailable")
}
t.Cleanup(func() { secureRandomRead = originalRead })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Idempotency-Key") != "fixed-key" {
t.Fatalf("explicit idempotency key missing: %q", r.Header.Get("Idempotency-Key"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Stream{
StreamID: "str_fixed", SystemID: "sys_fixed", UseCase: "ai-governance", PolicyID: "policy-main", Status: "active", Created: true,
})
}))
defer server.Close()
client, err := NewClient(testAPIKey, WithBaseURL(server.URL), WithMaxRetries(1))
if err != nil {
t.Fatalf("client: %v", err)
}
stream, err := client.CreateStream(
context.Background(),
StreamCreateInput{UseCase: "ai-governance", PolicyID: "policy-main"},
RequestOptions{IdempotencyKey: "fixed-key"},
)
if err != nil {
t.Fatalf("create stream with explicit idempotency key: %v", err)
}
if stream.StreamID != "str_fixed" {
t.Fatalf("unexpected stream: %#v", stream)
}
}
+97
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 {}
}
+274
View File
@@ -0,0 +1,274 @@
package main
// [D.5] `attesto connector init <slug>` — scaffold a marketplace-ready
// connector: a v2 manifest that passes connectorkit validation locally, a
// signed-webhook starter 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, publisherSlug, publisherName, repositoryURL, docsURL, providerURL, canaryRef string,
) connectorkit.Manifest {
canaryStatus := "pending"
if strings.TrimSpace(canaryRef) != "" {
canaryStatus = "green"
}
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": publisherName, "slug": publisherSlug},
Repository: map[string]string{"url": repositoryURL},
Documentation: map[string]string{"url": docsURL},
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": providerURL,
},
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",
},
"canary": map[string]any{
"status": canaryStatus,
"ref": canaryRef,
},
},
InstallRequirements: map[string]any{
"tenantLoginRequired": true,
"entitlementRequired": true,
},
Changelog: []map[string]any{
{"version": "0.1.0", "changes": []string{"Initial scaffold."}},
},
}
}
const webhookHandlerTemplate = `"""Signed-webhook verification helper 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")
return {"ok": True, "authenticatedPayloadBytes": len(body)}
`
const connectorReadmeTemplate = `# %s
Scaffolded by ` + "`attesto connector init`" + `.
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
event types and canary evidence when applicable).
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>)")
publisherSlug := fs.String("publisher-slug", "", "publisher slug")
publisherName := fs.String("publisher-name", "", "publisher display name")
repositoryURL := fs.String("repository-url", "", "HTTPS repository URL for this connector")
docsURL := fs.String("docs-url", "https://docs.attesto.eu/manuals/connectors.html", "HTTPS documentation URL")
providerURL := fs.String("provider-url", "", "HTTPS provider/product URL")
canaryRef := fs.String("canary-ref", "", "optional real canary/release evidence reference; required before publication")
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)
}
if strings.TrimSpace(*publisherSlug) == "" || strings.TrimSpace(*publisherName) == "" {
return errors.New("--publisher-slug and --publisher-name are required; connector init never writes placeholder publisher metadata")
}
if strings.TrimSpace(*repositoryURL) == "" || strings.TrimSpace(*providerURL) == "" {
return errors.New("--repository-url and --provider-url are required; connector init never writes placeholder URLs")
}
if strings.TrimSpace(*docsURL) == "" {
return errors.New("--docs-url is required")
}
connectorName := *name
if connectorName == "" {
connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck
}
manifest := connectorManifestTemplate(
slug,
connectorName,
*category,
*publisherSlug,
*publisherName,
*repositoryURL,
*docsURL,
*providerURL,
*canaryRef,
)
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(webhookHandlerTemplate, connectorName)
if err := os.WriteFile(filepath.Join(outDir, "webhook_handler.py"), []byte(handler), 0o644); err != nil {
return err
}
readme := fmt.Sprintf(connectorReadmeTemplate, 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": "implement runtime methods, attach real canary evidence, re-run with --validate-only until OK",
})
}
+81
View File
@@ -0,0 +1,81 @@
package main
// [D.5] connector init scaffolds a manifest whose ONLY validation finding is
// the pending canary, and the generated webhook starter 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,
"--publisher-slug", "attesto",
"--publisher-name", "Attesto",
"--repository-url", "https://git.rotz.ai/rotzmediagroup/my-crm-evidence",
"--provider-url", "https://attesto.eu",
}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(raw)), "change me") ||
strings.Contains(strings.ToLower(string(raw)), "change-me") ||
strings.Contains(strings.ToLower(string(raw)), "example.com") {
t.Fatalf("scaffold contains placeholder metadata: %s", string(raw))
}
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)
}
}
handler, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(handler), "from attesto.webhooks import verify_webhook") {
t.Fatal("generated handler does not use the P1.4 helper")
}
// re-running must refuse to overwrite
if err := a.connectorInit([]string{
"my-crm-evidence",
"--dir", dir,
"--publisher-slug", "attesto",
"--publisher-name", "Attesto",
"--repository-url", "https://git.rotz.ai/rotzmediagroup/my-crm-evidence",
"--provider-url", "https://attesto.eu",
}); err == nil {
t.Fatal("expected overwrite refusal")
}
}
func TestConnectorInitRejectsMissingRealMetadata(t *testing.T) {
dir := filepath.Join(t.TempDir(), "my-crm")
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir})
if err == nil {
t.Fatal("expected connector init to require real metadata")
}
if _, statErr := os.Stat(filepath.Join(dir, "attesto.connector.json")); !os.IsNotExist(statErr) {
t.Fatalf("connector init wrote files after missing metadata error: %v", statErr)
}
}
+99 -6
View File
@@ -22,7 +22,7 @@ import (
"go.attesto.eu/sdk/connectorkit"
)
const cliVersion = "0.3.0"
const cliVersion = "0.5.0"
var supportedVerifyKindNames = []string{
"receipt",
@@ -137,6 +137,12 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
return a.quorum(ctx, args[1:])
case "ivc":
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":
return a.connectors(ctx, args[1:])
case "local-vault":
@@ -145,6 +151,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error {
return a.marketplace(ctx, args[1:])
case "doctor":
return a.doctor(ctx, args[1:])
case "report":
return a.report(ctx, args[1:])
case "readiness":
return a.readiness(args[1:])
default:
@@ -168,6 +176,30 @@ func (a *app) verify(ctx context.Context, args []string) error {
return errors.New("--file is required")
}
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:
_ = ctx
return fmt.Errorf("unknown verify subcommand: %s", args[0])
@@ -444,8 +476,9 @@ func (a *app) doctor(ctx context.Context, args []string) error {
}
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) {
if err := headStore.SetWithError("__doctor__", 1, strings.Repeat("0", 64)); err != nil {
fail("head_store", err)
} else 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"))
@@ -1018,6 +1051,12 @@ func (a *app) localVaultSpool(args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*spoolFile) == "" {
return errors.New("--spool-file is required")
}
if strings.TrimSpace(*file) == "" {
return errors.New("--file is required")
}
raw, err := os.ReadFile(*file)
if err != nil {
return err
@@ -1033,12 +1072,26 @@ func (a *app) localVaultSpool(args []string) error {
if err != nil {
return err
}
defer fh.Close()
canonical, err := attesto.CanonicalJSON(obj)
if err != nil {
_ = fh.Close()
return err
}
if _, err := fh.Write(append(canonical, '\n')); err != nil {
line := append(canonical, '\n')
written, err := fh.Write(line)
if err != nil {
_ = fh.Close()
return err
}
if written != len(line) {
_ = fh.Close()
return fmt.Errorf("short spool write: wrote %d of %d bytes", written, len(line))
}
if err := fh.Sync(); err != nil {
_ = fh.Close()
return err
}
if err := fh.Close(); err != nil {
return err
}
return a.write(map[string]any{"ok": true, "spoolFile": *spoolFile})
@@ -1748,7 +1801,47 @@ func writeConfig(path string, cfg cliConfig) error {
if err != nil {
return err
}
return os.WriteFile(path, append(raw, '\n'), 0o600)
return writeFileAtomic0600(path, append(raw, '\n'))
}
func writeFileAtomic0600(path string, body []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-")
if err != nil {
return err
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return err
}
written, err := tmp.Write(body)
if err != nil {
_ = tmp.Close()
return err
}
if written != len(body) {
_ = tmp.Close()
return fmt.Errorf("short atomic write: wrote %d of %d bytes", written, len(body))
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
removeTmp = false
return nil
}
func redactValue(value any) any {
+163 -8
View File
@@ -156,6 +156,84 @@ func TestConfigSetRedactsSecrets(t *testing.T) {
if !strings.Contains(string(raw), cliTestAPIKey) {
t.Fatalf("config did not persist api key")
}
info, err := os.Stat(config)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("config mode = %o, want 600", info.Mode().Perm())
}
matches, err := filepath.Glob(filepath.Join(dir, ".config.json.tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("atomic config temp files leaked: %v", matches)
}
}
func TestConfigSetFailsWhenConfigPathIsDirectory(t *testing.T) {
dir := t.TempDir()
env := testEnv(t, map[string]string{
"ATTESTO_CONFIG": dir,
"ATT_API_KEY": cliTestAPIKey,
})
var stdout, stderr bytes.Buffer
code := run([]string{"--json", "config", "set", "--api-key-env", "ATT_API_KEY"}, &stdout, &stderr, env)
if code == 0 {
t.Fatal("config set must fail when config path is a directory")
}
if strings.Contains(stdout.String(), cliTestAPIKey) || strings.Contains(stderr.String(), cliTestAPIKey) {
t.Fatalf("secret leaked on write failure: stdout=%s stderr=%s", stdout.String(), stderr.String())
}
}
func TestLocalVaultSpoolAndStatus(t *testing.T) {
dir := t.TempDir()
eventFile := filepath.Join(dir, "event.json")
spoolFile := filepath.Join(dir, "spool", "events.jsonl")
if err := os.WriteFile(eventFile, []byte(`{"z":2,"a":1}`), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := run([]string{"--json", "local-vault", "spool", "--spool-file", spoolFile, "--file", eventFile}, &stdout, &stderr, testEnv(t, nil))
if code != 0 {
t.Fatalf("exit=%d stderr=%s", code, stderr.String())
}
raw, err := os.ReadFile(spoolFile)
if err != nil {
t.Fatal(err)
}
if string(raw) != "{\"a\":1,\"z\":2}\n" {
t.Fatalf("spool must write canonical JSONL, got %q", raw)
}
info, err := os.Stat(spoolFile)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("spool mode = %o, want 600", info.Mode().Perm())
}
stdout.Reset()
stderr.Reset()
code = run([]string{"--json", "local-vault", "status", "--spool-file", spoolFile}, &stdout, &stderr, testEnv(t, nil))
if code != 0 {
t.Fatalf("exit=%d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), `"events": 1`) || !strings.Contains(stdout.String(), `"ok": true`) {
t.Fatalf("unexpected status output: %s", stdout.String())
}
}
func TestLocalVaultSpoolRequiresPaths(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"--json", "local-vault", "spool", "--spool-file", filepath.Join(t.TempDir(), "events.jsonl")}, &stdout, &stderr, testEnv(t, nil))
if code == 0 {
t.Fatal("missing --file must fail")
}
if !strings.Contains(stderr.String(), "--file is required") {
t.Fatalf("missing required path error: %s", stderr.String())
}
}
func TestStreamsCreateCallsAPI(t *testing.T) {
@@ -198,10 +276,10 @@ func TestMarketplaceInitAndValidate(t *testing.T) {
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
"--publisher-slug", "acme",
"--publisher-name", "ACME",
"--repository-url", "https://git.example.com/acme/risk-connector",
"--docs-url", "https://docs.example.com/acme/risk-connector",
"--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
"--capabilities", "proofstream,offline-verification",
"--provider-url", "https://example.com/acme-risk",
"--provider-url", "https://attesto.eu/connectors/acme-risk",
"--auth-mode", "signed-webhook",
"--auth-scopes", "repository:read",
"--sync-modes", "webhook",
@@ -226,6 +304,83 @@ func TestMarketplaceInitAndValidate(t *testing.T) {
}
}
func TestMarketplaceInitRejectsMissingCanaryEvidenceRef(t *testing.T) {
dir := t.TempDir()
manifestFile := filepath.Join(dir, "attesto.connector.json")
var stdout, stderr bytes.Buffer
code := run([]string{
"--json",
"marketplace",
"init",
"--output", manifestFile,
"--slug", "acme-risk-connector",
"--name", "ACME Risk Connector",
"--version", "1.0.0",
"--category", "ai-governance",
"--summary", "Produces Attesto evidence for ACME risk decisions.",
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
"--publisher-slug", "acme",
"--publisher-name", "ACME",
"--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
"--capabilities", "proofstream,offline-verification",
"--provider-url", "https://attesto.eu/connectors/acme-risk",
"--auth-mode", "signed-webhook",
"--auth-scopes", "repository:read",
"--sync-modes", "webhook",
"--event-types", "risk.decision.created",
}, &stdout, &stderr, testEnv(t, nil))
if code != 0 {
t.Fatalf("init exit=%d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "runtime.canary_ref") {
t.Fatalf("expected missing canary evidence finding: %s", stdout.String())
}
if _, err := os.Stat(manifestFile); !os.IsNotExist(err) {
t.Fatalf("marketplace init wrote a manifest without canary evidence: %v", err)
}
}
func TestMarketplaceInitRejectsPlaceholderMetadata(t *testing.T) {
dir := t.TempDir()
manifestFile := filepath.Join(dir, "attesto.connector.json")
var stdout, stderr bytes.Buffer
code := run([]string{
"--json",
"marketplace",
"init",
"--output", manifestFile,
"--slug", "acme-risk-connector",
"--name", "ACME Risk Connector",
"--version", "1.0.0",
"--category", "ai-governance",
"--summary", "Produces Attesto evidence for ACME risk decisions.",
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
"--publisher-slug", "acme",
"--publisher-name", "ACME",
"--repository-url", "https://example.com/acme/risk-connector",
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
"--capabilities", "proofstream,offline-verification",
"--provider-url", "https://attesto.eu/connectors/acme-risk",
"--auth-mode", "signed-webhook",
"--auth-scopes", "repository:read",
"--sync-modes", "webhook",
"--event-types", "risk.decision.created",
"--canary-ref", "attesto-owned-test-account-2026-06-09",
}, &stdout, &stderr, testEnv(t, nil))
if code != 0 {
t.Fatalf("init exit=%d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "manifest.placeholder") {
t.Fatalf("expected placeholder metadata finding: %s", stdout.String())
}
if _, err := os.Stat(manifestFile); !os.IsNotExist(err) {
t.Fatalf("marketplace init wrote a manifest with placeholder metadata: %v", err)
}
}
func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
dir := t.TempDir()
manifestFile := filepath.Join(dir, "attesto.connector.json")
@@ -245,7 +400,7 @@ func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("submit json: %v", err)
}
if body["sourceRef"] != "https://git.example.com/acme/risk-connector/releases/v1.0.0" {
if body["sourceRef"] != "https://git.rotz.ai/rotzmediagroup/acme-risk-connector/releases/v1.0.0" {
t.Fatalf("unexpected sourceRef: %#v", body["sourceRef"])
}
_, _ = w.Write([]byte(`{"asset":{"slug":"acme-risk-connector","name":"ACME Risk Connector"},"validation":{"ok":true},"evidence":{"action":"asset_validation_finished","receiptHash":"rh","payloadHash":"ph"}}`))
@@ -271,7 +426,7 @@ func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
"--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN",
"marketplace", "submit",
"--manifest-file", manifestFile,
"--source-ref", "https://git.example.com/acme/risk-connector/releases/v1.0.0",
"--source-ref", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector/releases/v1.0.0",
"--visibility", "public",
"--pricing-model", "free",
}, &stdout, &stderr, env)
@@ -330,10 +485,10 @@ func writeMarketplaceManifest(t *testing.T, path string) {
"name": "ACME",
},
"repository": map[string]any{
"url": "https://git.example.com/acme/risk-connector",
"url": "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
},
"documentation": map[string]any{
"url": "https://docs.example.com/acme/risk-connector",
"url": "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
},
"capabilities": []string{"proofstream", "offline-verification"},
"evidence": map[string]bool{
@@ -349,7 +504,7 @@ func writeMarketplaceManifest(t *testing.T, path string) {
"provider": map[string]any{
"id": "acme-risk-connector",
"name": "ACME Risk Connector",
"websiteUrl": "https://example.com/acme-risk",
"websiteUrl": "https://attesto.eu/connectors/acme-risk",
},
"auth": map[string]any{
"mode": "signed-webhook",
+198
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
}
+58
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")
}
}
+73 -1
View File
@@ -1,6 +1,9 @@
package connectorkit
import "regexp"
import (
"regexp"
"strings"
)
type Manifest struct {
SchemaVersion string `json:"schemaVersion"`
@@ -93,6 +96,7 @@ func ValidateManifest(manifest Manifest) ValidationResult {
}
if manifest.AssetType == "connector" && manifest.SchemaVersion == "attesto.connector.v2" {
findings = appendV2Findings(manifest, findings)
findings = appendPlaceholderFindings(manifest, findings)
}
score := 0
if len(findings) == 0 {
@@ -142,6 +146,15 @@ func ValidateManifest(manifest Manifest) ValidationResult {
}
func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
if strings.TrimSpace(manifest.Publisher["slug"]) == "" || strings.TrimSpace(manifest.Publisher["name"]) == "" {
findings = append(findings, Finding{"publisher.invalid", "error", "publisher.slug and publisher.name are required"})
}
if strings.TrimSpace(manifest.Repository["url"]) == "" {
findings = append(findings, Finding{"repository.invalid", "error", "repository.url is required"})
}
if strings.TrimSpace(manifest.Documentation["url"]) == "" {
findings = append(findings, Finding{"documentation.invalid", "error", "documentation.url is required"})
}
if !hasRequiredKeys(manifest.Provider, []string{"id", "name", "websiteUrl"}) {
findings = append(findings, Finding{"provider.invalid", "error", "provider.id, provider.name and provider.websiteUrl are required"})
}
@@ -200,6 +213,8 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
canary, ok := manifest.Runtime["canary"].(map[string]any)
if !ok || canary["status"] != "green" {
findings = append(findings, Finding{"runtime.canary", "error", "runtime.canary.status must be green before publication"})
} else if strings.TrimSpace(toString(canary["ref"])) == "" {
findings = append(findings, Finding{"runtime.canary_ref", "error", "runtime.canary.ref must point to real canary or release evidence"})
}
}
if !hasRequiredKeys(manifest.InstallRequirements, []string{"tenantLoginRequired", "entitlementRequired"}) {
@@ -213,6 +228,63 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
return findings
}
func appendPlaceholderFindings(manifest Manifest, findings []Finding) []Finding {
fields := map[string]any{
"publisher": manifest.Publisher,
"repository": manifest.Repository,
"documentation": manifest.Documentation,
"provider": manifest.Provider,
"runtime": manifest.Runtime,
}
for field, value := range fields {
if containsPlaceholder(value) {
findings = append(findings, Finding{
Code: "manifest.placeholder",
Severity: "error",
Message: field + " contains placeholder/example metadata; production connector manifests require real values",
})
}
}
return findings
}
func containsPlaceholder(value any) bool {
switch typed := value.(type) {
case string:
normalized := strings.ToLower(strings.TrimSpace(typed))
for _, marker := range []string{"change me", "change-me", "change_me", "example.com", "example.org", "example.net"} {
if strings.Contains(normalized, marker) {
return true
}
}
case map[string]string:
for _, nested := range typed {
if containsPlaceholder(nested) {
return true
}
}
case map[string]any:
for _, nested := range typed {
if containsPlaceholder(nested) {
return true
}
}
case []string:
for _, nested := range typed {
if containsPlaceholder(nested) {
return true
}
}
case []any:
for _, nested := range typed {
if containsPlaceholder(nested) {
return true
}
}
}
return false
}
func hasRequiredKeys(value map[string]any, keys []string) bool {
if value == nil {
return false
+54
View File
@@ -58,3 +58,57 @@ func TestValidateV2ManifestRequiresRuntimeMetadata(t *testing.T) {
t.Fatalf("missing runtime finding: %+v", result.Findings)
}
}
func TestValidateV2ManifestRejectsEmptyCanaryRef(t *testing.T) {
raw, err := os.ReadFile(filepath.Clean("../../../connectors/github/attesto.connector.json"))
if err != nil {
t.Fatal(err)
}
var manifest Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
manifest.Runtime["canary"].(map[string]any)["ref"] = ""
result := ValidateManifest(manifest)
if result.OK {
t.Fatalf("expected invalid manifest without canary evidence ref")
}
var found bool
for _, finding := range result.Findings {
if finding.Code == "runtime.canary_ref" {
found = true
}
}
if !found {
t.Fatalf("missing canary ref finding: %+v", result.Findings)
}
}
func TestValidateV2ManifestRejectsPlaceholderMetadata(t *testing.T) {
raw, err := os.ReadFile(filepath.Clean("../../../connectors/github/attesto.connector.json"))
if err != nil {
t.Fatal(err)
}
var manifest Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
manifest.Repository["url"] = "https://example.com/change-me/github"
result := ValidateManifest(manifest)
if result.OK {
t.Fatalf("expected invalid manifest with placeholder metadata")
}
var found bool
for _, finding := range result.Findings {
if finding.Code == "manifest.placeholder" {
found = true
}
}
if !found {
t.Fatalf("missing placeholder finding: %+v", result.Findings)
}
}
+218
View File
@@ -0,0 +1,218 @@
package attesto
// Go parity on offline disclosure verification.
//
// The fixture is a presentation the Local Vault really built through the real
// edge core. Three implementations agreeing with each other proves less than
// three agreeing with the producer, which is why it is not written to satisfy
// the verifiers.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func loadPresentation(t *testing.T) map[string]any {
t.Helper()
path := filepath.Join(
"..", "..", "golden-vectors", "provenance-v0.1-dev",
"provenance-disclosure-presentation-valid.json",
)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var vector map[string]any
if err := json.Unmarshal(raw, &vector); err != nil {
t.Fatalf("parse fixture: %v", err)
}
return vector
}
func presentationCopy(t *testing.T, vector map[string]any) map[string]any {
t.Helper()
raw, err := json.Marshal(vector["presentation"])
if err != nil {
t.Fatalf("copy: %v", err)
}
var copied map[string]any
if err := json.Unmarshal(raw, &copied); err != nil {
t.Fatalf("copy: %v", err)
}
return copied
}
func TestARealDisclosureVerifiesAcrossLanguages(t *testing.T) {
vector := loadPresentation(t)
report := VerifyDisclosure(
vector["presentation"].(map[string]any),
WithExpectedNonce(vector["nonce"].(string)),
)
if !report.Ok {
t.Fatalf("a real disclosure did not verify: %v", report.Problems)
}
expected := vector["expected"].(map[string]any)
if float64(len(report.VerifiedLeaves)) != expected["verified_leaf_count"].(float64) {
t.Fatalf("verified %d leaves, expected %v", len(report.VerifiedLeaves), expected["verified_leaf_count"])
}
if report.Freshness != "challenge" {
t.Fatalf("freshness: got %q want challenge", report.Freshness)
}
}
func TestWithoutAChallengeTheReportSaysSo(t *testing.T) {
// Weaker evidence, reported as weaker rather than presented as the same.
vector := loadPresentation(t)
report := VerifyDisclosure(vector["presentation"].(map[string]any))
if !report.Ok {
t.Fatalf("expiry-bounded verification failed: %v", report.Problems)
}
if report.Freshness != "bounded_lifetime" {
t.Fatalf("freshness: got %q want bounded_lifetime", report.Freshness)
}
}
func TestASwappedValueDoesNotOpenItsLeaf(t *testing.T) {
vector := loadPresentation(t)
tampered := presentationCopy(t, vector)
revealed := tampered["revealed"].([]any)
entry := revealed[0].(map[string]any)
entry["value"].(map[string]any)["value"] = map[string]any{"manifest_count": "9"}
report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string)))
if report.Ok {
t.Fatal("a swapped value still verified")
}
if !hasDisclosureProblem(report.Problems, "does not open") {
t.Fatalf("expected an opening failure, got %v", report.Problems)
}
}
func TestALeafThatDoesNotFoldToTheRootIsRefused(t *testing.T) {
// A leaf can open its own commitment perfectly and still belong to a
// different capsule. Corrupting a sibling leaves the value and randomizer
// untouched, so only the two-hop fold can catch it — which is what
// distinguishes a verifier from a value checker.
vector := loadPresentation(t)
tampered := presentationCopy(t, vector)
proofs := tampered["inclusion_proofs"].([]any)
steps := proofs[0].(map[string]any)["subtree_steps"].([]any)
steps[0].(map[string]any)["sibling"] = strings.Repeat("0", 64)
report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string)))
if report.Ok || !hasDisclosureProblem(report.Problems, "not included") {
t.Fatalf("a leaf outside the capsule was accepted: %v", report.Problems)
}
}
func TestACorruptedTopPathIsRefused(t *testing.T) {
vector := loadPresentation(t)
tampered := presentationCopy(t, vector)
proofs := tampered["inclusion_proofs"].([]any)
steps := proofs[0].(map[string]any)["top_steps"].([]any)
steps[0].(map[string]any)["sibling"] = strings.Repeat("0", 64)
report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string)))
if report.Ok || !hasDisclosureProblem(report.Problems, "not included") {
t.Fatalf("a corrupted top path was accepted: %v", report.Problems)
}
}
func TestAReplayedNonceIsRefused(t *testing.T) {
vector := loadPresentation(t)
report := VerifyDisclosure(
vector["presentation"].(map[string]any),
WithExpectedNonce(strings.Repeat("ff", 32)),
)
if report.Ok || !hasDisclosureProblem(report.Problems, "nonce") {
t.Fatalf("a replayed nonce was accepted: %v", report.Problems)
}
}
func TestAnExpiredDisclosureIsRefused(t *testing.T) {
vector := loadPresentation(t)
report := VerifyDisclosure(
vector["presentation"].(map[string]any),
WithVerificationTime(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
)
if report.Ok || !hasDisclosureProblem(report.Problems, "expired") {
t.Fatalf("an expired disclosure was accepted: %v", report.Problems)
}
}
func TestADisclosureForAnotherAssetIsRefused(t *testing.T) {
vector := loadPresentation(t)
report := VerifyDisclosure(
vector["presentation"].(map[string]any),
WithSubjectCommitment(strings.Repeat("aa", 32)),
)
if report.Ok || report.SubjectChecked {
t.Fatalf("a foreign subject was accepted: %v", report.Problems)
}
}
func TestTheMatchingSubjectIsReportedAsChecked(t *testing.T) {
vector := loadPresentation(t)
report := VerifyDisclosure(
vector["presentation"].(map[string]any),
WithSubjectCommitment(vector["subject_commitment"].(string)),
)
if !report.Ok || !report.SubjectChecked {
t.Fatalf("the matching subject was not reported: %v", report.Problems)
}
}
func TestATamperedSignatureIsCaught(t *testing.T) {
vector := loadPresentation(t)
tampered := presentationCopy(t, vector)
tampered["nonce"] = strings.Repeat("cd", 32)
report := VerifyDisclosure(tampered)
if report.Ok || !hasDisclosureProblem(report.Problems, "signature") {
t.Fatalf("a tampered presentation was accepted: %v", report.Problems)
}
}
func TestEveryProblemIsCollected(t *testing.T) {
// A caller should see everything wrong with a presentation at once.
vector := loadPresentation(t)
tampered := presentationCopy(t, vector)
revealed := tampered["revealed"].([]any)
revealed[0].(map[string]any)["value"].(map[string]any)["value"] = map[string]any{"x": "y"}
report := VerifyDisclosure(
tampered,
WithExpectedNonce(strings.Repeat("ff", 32)),
WithVerificationTime(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
)
if len(report.Problems) < 3 {
t.Fatalf("expected several problems, got %v", report.Problems)
}
}
func TestTheReportSaysWhatItDoesNotClaim(t *testing.T) {
vector := loadPresentation(t)
report := VerifyDisclosure(vector["presentation"].(map[string]any))
if len(report.NotClaimed) != 1 || report.NotClaimed[0]["id"] != "undisclosed_facts_absent" {
t.Fatalf("the non-claim is missing: %v", report.NotClaimed)
}
}
func TestAnUnknownProtocolIsRefusedFirst(t *testing.T) {
report := VerifyDisclosure(map[string]any{"protocol": "SOMETHING-ELSE"})
if report.Ok || len(report.Problems) != 1 {
t.Fatalf("expected a single protocol refusal, got %v", report.Problems)
}
}
func hasDisclosureProblem(problems []string, needle string) bool {
for _, problem := range problems {
if strings.Contains(problem, needle) {
return true
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
package attesto
import "testing"
// Python derived assurance, TypeScript and Go did not. The rule that L3 is
// derived and never signed only holds if every client applies it, so parity
// here is the property rather than tidiness.
func boolPtr(value bool) *bool { return &value }
func TestL3IsDerivedAndNeverSigned(t *testing.T) {
report, err := EffectiveAssurance("L2", boolPtr(true), nil)
if err != nil {
t.Fatal(err)
}
if report.Effective != DerivedAssuranceLevel || !report.Derived {
t.Fatalf("expected derived L3, got %+v", report)
}
if report.VaultAssurance != "L2" {
t.Fatalf("the signed level must survive derivation, got %q", report.VaultAssurance)
}
if _, err := EffectiveAssurance("L3", nil, nil); err == nil {
t.Fatal("L3 was accepted as a signed vault assurance")
}
}
func TestAWithheldQuorumDoesNotReduceWhatTheVaultSigned(t *testing.T) {
for _, quorum := range []*bool{boolPtr(false), nil} {
report, err := EffectiveAssurance("L2", quorum, nil)
if err != nil {
t.Fatal(err)
}
if report.Effective != "L2" || report.Derived {
t.Fatalf("expected L2 withheld, got %+v", report)
}
}
}
func TestAnAnchorNeverPromotesAssurance(t *testing.T) {
report, err := EffectiveAssurance("L1", nil, boolPtr(true))
if err != nil {
t.Fatal(err)
}
if report.Effective != "L1" {
t.Fatalf("an anchor promoted the level to %q", report.Effective)
}
found := false
for _, reason := range report.Reasons {
if reason == "anchor confirmed; anchoring does not promote assurance" {
found = true
}
}
if !found {
t.Fatal("the report did not say that anchoring does not promote")
}
}
func TestTheTableAgreesWithTheOtherClients(t *testing.T) {
// Enumerated, because two implementations of a rule this narrow can only be
// shown to agree by listing every case.
cases := map[string]struct {
level string
quorum *bool
want string
}{
"L0/none": {"L0", nil, "L0"}, "L0/met": {"L0", boolPtr(true), "L0"},
"L0/unmet": {"L0", boolPtr(false), "L0"},
"L1/none": {"L1", nil, "L1"}, "L1/met": {"L1", boolPtr(true), "L1"},
"L1/unmet": {"L1", boolPtr(false), "L1"},
"L2/none": {"L2", nil, "L2"}, "L2/met": {"L2", boolPtr(true), "L3"},
"L2/unmet": {"L2", boolPtr(false), "L2"},
}
for name, item := range cases {
report, err := EffectiveAssurance(item.level, item.quorum, nil)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if report.Effective != item.want {
t.Fatalf("%s: got %q want %q", name, report.Effective, item.want)
}
}
}
func TestAnUnknownLevelIsRefused(t *testing.T) {
for _, level := range []string{"L9", "", "l2"} {
if _, err := EffectiveAssurance(level, nil, nil); err == nil {
t.Fatalf("%q was accepted as a vault assurance", level)
}
}
}
+135
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
}
+161
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
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)
}
}
+63 -12
View File
@@ -15,6 +15,11 @@ type HeadStore interface {
Set(streamID string, seqNo int64, eventHash string)
}
type errorAwareHeadStore interface {
HeadStore
SetWithError(streamID string, seqNo int64, eventHash string) error
}
// 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 {
@@ -58,6 +63,11 @@ func (s *MemoryHeadStore) Set(streamID string, seqNo int64, eventHash string) {
s.heads[streamID] = [2]any{seqNo, eventHash}
}
func (s *MemoryHeadStore) SetWithError(streamID string, seqNo int64, eventHash string) error {
s.Set(streamID, seqNo, eventHash)
return nil
}
// FileHeadStore persists heads to a JSON file (default ~/.attesto/heads.json),
// giving fork detection across separate process invocations. Writes are atomic.
type FileHeadStore struct {
@@ -78,15 +88,26 @@ func NewFileHeadStore(path string) *FileHeadStore {
}
func (s *FileHeadStore) load() map[string][2]json.RawMessage {
raw, err := os.ReadFile(s.path)
out, err := s.loadWithError()
if err != nil {
return map[string][2]json.RawMessage{}
}
return out
}
func (s *FileHeadStore) loadWithError() (map[string][2]json.RawMessage, error) {
raw, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return map[string][2]json.RawMessage{}, nil
}
if err != nil {
return nil, err
}
out := map[string][2]json.RawMessage{}
if err := json.Unmarshal(raw, &out); err != nil {
return map[string][2]json.RawMessage{}
return nil, err
}
return out
return out, nil
}
func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
@@ -105,37 +126,59 @@ func (s *FileHeadStore) Get(streamID string) (int64, string, bool) {
}
func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
_ = s.SetWithError(streamID, seqNo, eventHash)
}
func (s *FileHeadStore) SetWithError(streamID string, seqNo int64, eventHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
heads := map[string][2]any{}
for key, entry := range s.load() {
stored, err := s.loadWithError()
if err != nil {
return err
}
for key, entry := range stored {
var n int64
var h string
_ = json.Unmarshal(entry[0], &n)
_ = json.Unmarshal(entry[1], &h)
if err := json.Unmarshal(entry[0], &n); err != nil {
return fmt.Errorf("load stored head seq for %s: %w", key, err)
}
if err := json.Unmarshal(entry[1], &h); err != nil {
return fmt.Errorf("load stored head hash for %s: %w", key, err)
}
heads[key] = [2]any{n, h}
}
heads[streamID] = [2]any{seqNo, eventHash}
body, err := json.Marshal(heads)
if err != nil {
return
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return
return err
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".heads-")
if err != nil {
return
return err
}
tmpName := tmp.Name()
_, writeErr := tmp.Write(body)
closeErr := tmp.Close()
if writeErr != nil || closeErr != nil {
_ = os.Remove(tmpName)
return
if writeErr != nil {
return writeErr
}
_ = os.Chmod(tmpName, 0o600)
_ = os.Rename(tmpName, s.path)
return closeErr
}
if err := os.Chmod(tmpName, 0o600); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return err
}
return nil
}
// checkAndAdvanceHead verifies a receipt extends the stored head, then advances
@@ -144,6 +187,11 @@ func (s *FileHeadStore) Set(streamID string, seqNo int64, eventHash string) {
// 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{
@@ -155,6 +203,9 @@ func checkAndAdvanceHead(store HeadStore, receipt EventReceipt) error {
}
}
}
if errorAware, ok := store.(errorAwareHeadStore); ok {
return errorAware.SetWithError(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
}
store.Set(receipt.StreamID, receipt.SeqNo, receipt.EventHash)
return nil
}
+43
View File
@@ -80,3 +80,46 @@ func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
t.Error("expected fork on reopened store")
}
}
func TestFileHeadStorePersistenceFailureReturned(t *testing.T) {
path := t.TempDir()
store := NewFileHeadStore(path)
if err := checkAndAdvanceHead(store, receipt(1, "h1", "")); err == nil {
t.Fatal("expected persistence failure when head-store path is a directory")
}
}
func TestFileHeadStoreCorruptFileFailsClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "heads.json")
if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil {
t.Fatal(err)
}
store := NewFileHeadStore(path)
if err := store.SetWithError("str_demo", 1, "h1"); err == nil {
t.Fatal("expected corrupt head store to fail closed")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(raw) != "{not-json" {
t.Fatal("corrupt head store must not be silently overwritten")
}
}
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")
}
}
+1024
View File
File diff suppressed because it is too large Load Diff
+727
View File
@@ -0,0 +1,727 @@
package attesto
// Go parity against the Rust-normative provenance corpus.
//
// Rust (edge/) produced golden-vectors/provenance-v0.1-dev/. Go conforms iff it
// reproduces every "valid" vector byte-for-byte and refuses every "invalid" one.
// A corpus of only positive cases would prove the implementations can agree,
// not that either can refuse.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func provenanceVectorDir(t *testing.T) string {
t.Helper()
dir := filepath.Join("..", "..", "golden-vectors", "provenance-v0.1-dev")
if _, err := os.Stat(dir); err != nil {
t.Fatalf("provenance vectors missing at %s: %v", dir, err)
}
return dir
}
func loadProvenanceVector(t *testing.T, name string) map[string]any {
t.Helper()
path := filepath.Join(provenanceVectorDir(t), name+".json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var vector map[string]any
if err := json.Unmarshal(raw, &vector); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return vector
}
func TestProvenanceCorpusIsPresentAndTyped(t *testing.T) {
entries, err := filepath.Glob(filepath.Join(provenanceVectorDir(t), "*.json"))
if err != nil || len(entries) == 0 {
t.Fatalf("no provenance vectors: %v", err)
}
invalid := 0
for _, entry := range entries {
raw, err := os.ReadFile(entry)
if err != nil {
t.Fatalf("read %s: %v", entry, err)
}
var vector map[string]any
if err := json.Unmarshal(raw, &vector); err != nil {
t.Fatalf("parse %s: %v", entry, err)
}
if vector["protocol"] != ProvenanceProtocol {
t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"])
}
// Four outcomes, deliberately distinct. "invalid" means the check ran
// and answered no; "rejected" means the input was refused before any
// check could run; "differs" is not a validity claim but a requirement
// that two values not be equal.
switch vector["expectation"] {
case "valid", "differs":
case "invalid", "rejected":
invalid++
default:
t.Fatalf("%s: bad expectation %v", entry, vector["expectation"])
}
if vector["requires"] == nil {
t.Fatalf("%s: does not declare what it requires", entry)
}
}
if invalid < 5 {
t.Fatalf("corpus carries only %d negative vectors", invalid)
}
}
func TestProvenancePinnedRandomizerReproducesRustDigest(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-commitment-valid")
digest, err := ProvenanceCommitmentDigest(
vector["domain"].(string),
vector["value"],
vector["randomizer"].(string),
)
if err != nil {
t.Fatalf("commit: %v", err)
}
if digest != vector["expected_digest"].(string) {
t.Fatalf("digest mismatch:\n got %s\nwant %s", digest, vector["expected_digest"])
}
ok, err := VerifyProvenanceCommitment(
vector["domain"].(string),
vector["value"],
vector["randomizer"].(string),
vector["expected_digest"].(string),
)
if err != nil || !ok {
t.Fatalf("verify failed: ok=%v err=%v", ok, err)
}
}
func TestProvenanceSameValueDifferentRandomizerIsUnlinkable(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-commitment-randomizer-diff")
seen := map[string]bool{}
for _, entry := range vector["cases"].([]any) {
testCase := entry.(map[string]any)
digest, err := ProvenanceCommitmentDigest(
vector["domain"].(string), vector["value"], testCase["randomizer"].(string),
)
if err != nil {
t.Fatalf("commit: %v", err)
}
if digest != testCase["expected_digest"].(string) {
t.Fatalf("digest mismatch: got %s want %s", digest, testCase["expected_digest"])
}
if seen[digest] {
t.Fatal("two randomizers produced the same commitment")
}
seen[digest] = true
}
}
func TestProvenanceSameValueAcrossDomainsDoesNotCollide(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-commitment-cross-domain")
seen := map[string]bool{}
for _, entry := range vector["cases"].([]any) {
testCase := entry.(map[string]any)
digest, err := ProvenanceCommitmentDigest(
testCase["domain"].(string), vector["value"], vector["randomizer"].(string),
)
if err != nil {
t.Fatalf("commit: %v", err)
}
if digest != testCase["expected_digest"].(string) {
t.Fatalf("digest mismatch: got %s want %s", digest, testCase["expected_digest"])
}
if seen[digest] {
t.Fatal("two domains produced the same commitment")
}
seen[digest] = true
}
}
func TestProvenanceUnknownDomainIsRefused(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-commitment-invalid-domain")
if _, err := ProvenanceCommitmentDigest(
vector["domain"].(string), vector["value"], vector["randomizer"].(string),
); err == nil {
t.Fatal("an unknown domain must not resolve to a fallback")
}
}
func TestProvenanceMalformedRandomizersAreRefused(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-commitment-invalid-randomizer")
for _, entry := range vector["cases"].([]any) {
testCase := entry.(map[string]any)
if _, err := ProvenanceCommitmentDigest(
vector["domain"].(string), vector["value"], testCase["randomizer"].(string),
); err == nil {
t.Fatalf("randomizer %q must be refused", testCase["randomizer"])
}
}
}
func TestProvenanceDomainRegistryMatchesRust(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-domain-registry")
domains := vector["domains"].([]any)
// Nineteen provenance domains (ADR-0014 added the bundle tree) plus the
// three REVIEW-02 protocols that own their own preimage spaces: disclosure
// v2 and the ZK range statement and transcript.
const expected = 22
if len(domains) != len(ProvenanceDomains) || len(domains) != expected {
t.Fatalf(
"registry size mismatch: vector=%d go=%d expected=%d",
len(domains), len(ProvenanceDomains), expected,
)
}
for _, domain := range domains {
if _, ok := ProvenanceDomains[domain.(string)]; !ok {
t.Fatalf("missing domain %s", domain)
}
}
for _, rejected := range vector["rejected"].([]any) {
if _, ok := ProvenanceDomains[rejected.(string)]; ok {
t.Fatalf("domain %s must not be in the registry", rejected)
}
}
}
func leafInputs(t *testing.T, raw any) []SubtreeLeafInput {
t.Helper()
encoded, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal leaves: %v", err)
}
var leaves []SubtreeLeafInput
if err := json.Unmarshal(encoded, &leaves); err != nil {
t.Fatalf("unmarshal leaves: %v", err)
}
return leaves
}
func buildSubtree(t *testing.T, subtree string, raw any) (string, []string) {
t.Helper()
ordered, err := OrderSubtreeLeaves(leafInputs(t, raw))
if err != nil {
t.Fatalf("order %s: %v", subtree, err)
}
merkle, err := SubtreeMerkleRoot(subtree, ordered)
if err != nil {
t.Fatalf("merkle %s: %v", subtree, err)
}
root, err := SubtreeRoot(subtree, merkle, len(ordered))
if err != nil {
t.Fatalf("root %s: %v", subtree, err)
}
return root, ordered
}
func TestProvenanceCapsuleRootReproducesRustForest(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
expected := vector["expected"].(map[string]any)
claimsRoot, claimsOrdered := buildSubtree(t, "claims", vector["claims"])
for index, want := range expected["claims_ordered_leaves"].([]any) {
if claimsOrdered[index] != want.(string) {
t.Fatalf("claims leaf %d mismatch", index)
}
}
if claimsRoot != expected["claims_root"].(string) {
t.Fatalf("claims_root mismatch:\n got %s\nwant %s", claimsRoot, expected["claims_root"])
}
evidenceRoot, _ := buildSubtree(t, "evidence", vector["evidence"])
if evidenceRoot != expected["evidence_root"].(string) {
t.Fatalf("evidence_root mismatch")
}
policyRoot, _ := buildSubtree(t, "policy_results", vector["policy_results"])
if policyRoot != expected["policy_results_root"].(string) {
t.Fatalf("policy_results_root mismatch")
}
randomizers := vector["top_randomizers"].(map[string]any)
commitments := map[string]string{
"subject_commitment": vector["subject_commitment"].(string),
"claims_root": claimsRoot,
"evidence_root": evidenceRoot,
"policy_results_root": policyRoot,
"attestation_commitment": vector["attestation_commitment"].(string),
"vault_identity_commitment": vector["vault_identity_commitment"].(string),
}
digests := make([]string, 0, len(TopLeafRoles))
for _, role := range TopLeafRoles {
digest, err := TopLeafDigest(role, commitments[role], randomizers[role].(string))
if err != nil {
t.Fatalf("top leaf %s: %v", role, err)
}
digests = append(digests, digest)
}
for index, want := range expected["top_leaf_digests"].([]any) {
if digests[index] != want.(string) {
t.Fatalf("top leaf digest %d mismatch", index)
}
}
root, err := CapsuleRoot(digests)
if err != nil {
t.Fatalf("capsule root: %v", err)
}
if root != expected["capsule_root"].(string) {
t.Fatalf("capsule_root mismatch:\n got %s\nwant %s", root, expected["capsule_root"])
}
}
func TestProvenanceOddNodesArePromotedNotDuplicated(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-capsule-root-promoted-odd-node")
if vector["rule"] != "promote-odd-node" {
t.Fatalf("unexpected rule %v", vector["rule"])
}
seen := map[string]bool{}
for _, entry := range vector["cases"].([]any) {
testCase := entry.(map[string]any)
ordered, err := OrderSubtreeLeaves(leafInputs(t, testCase["leaves"]))
if err != nil {
t.Fatalf("order: %v", err)
}
merkle, err := SubtreeMerkleRoot("claims", ordered)
if err != nil {
t.Fatalf("merkle: %v", err)
}
if merkle != testCase["expected_merkle_root"].(string) {
t.Fatalf("merkle mismatch for %v leaves", testCase["leaf_count"])
}
if seen[merkle] {
t.Fatal("two tree sizes shared a root — odd node was duplicated")
}
seen[merkle] = true
}
}
func TestProvenanceLeafOrderIsIndependentOfCallerOrder(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
leaves := leafInputs(t, vector["claims"])
reversed := make([]SubtreeLeafInput, len(leaves))
for index, leaf := range leaves {
reversed[len(leaves)-1-index] = leaf
}
forward, err := OrderSubtreeLeaves(leaves)
if err != nil {
t.Fatalf("order forward: %v", err)
}
backward, err := OrderSubtreeLeaves(reversed)
if err != nil {
t.Fatalf("order reversed: %v", err)
}
for index := range forward {
if forward[index] != backward[index] {
t.Fatal("leaf ordering depended on caller order")
}
}
}
func loadProof(t *testing.T, name string) (TwoHopProof, bool) {
t.Helper()
vector := loadProvenanceVector(t, name)
encoded, err := json.Marshal(vector["proof"])
if err != nil {
t.Fatalf("marshal proof: %v", err)
}
var proof TwoHopProof
if err := json.Unmarshal(encoded, &proof); err != nil {
t.Fatalf("unmarshal proof: %v", err)
}
return proof, vector["expected_verified"].(bool)
}
func TestProvenanceValidDisclosureVerifies(t *testing.T) {
proof, expected := loadProof(t, "provenance-disclosure-valid")
ok, err := VerifyTwoHop(proof)
if err != nil {
t.Fatalf("verify: %v", err)
}
if ok != expected {
t.Fatalf("verification mismatch: got %v want %v", ok, expected)
}
}
func TestProvenanceInvalidDisclosuresAreRefused(t *testing.T) {
for _, name := range []string{
"provenance-disclosure-cross-tree-leaf",
"provenance-disclosure-invalid-inclusion",
"provenance-disclosure-foreign-leaf",
} {
proof, expected := loadProof(t, name)
if expected {
t.Fatalf("%s should be a negative vector", name)
}
ok, err := VerifyTwoHop(proof)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if ok {
t.Fatalf("%s verified but must not", name)
}
}
}
func TestProvenanceClaimLeafCannotBeReaimedAtEvidenceRoot(t *testing.T) {
valid, _ := loadProof(t, "provenance-disclosure-valid")
cross, _ := loadProof(t, "provenance-disclosure-cross-tree-leaf")
if cross.Leaf != valid.Leaf {
t.Fatal("the vector must reuse the same claim leaf")
}
if cross.Subtree != "evidence" {
t.Fatalf("expected the evidence side, got %s", cross.Subtree)
}
ok, err := VerifyTwoHop(cross)
if err != nil {
t.Fatalf("verify: %v", err)
}
if ok {
t.Fatal("a claim leaf verified against the evidence root")
}
}
func TestProvenanceCanonicalJSONMatchesRust(t *testing.T) {
vector := loadProvenanceVector(t, "provenance-canonical-json")
for _, entry := range vector["accepted"].([]any) {
testCase := entry.(map[string]any)
rendered, err := CanonicalJSON(testCase["value"])
if err != nil {
t.Fatalf("canonical: %v", err)
}
if string(rendered) != testCase["canonical_json"].(string) {
t.Fatalf("canonical mismatch:\n got %s\nwant %s", rendered, testCase["canonical_json"])
}
}
}
// --------------------------------------------------- canonical claim descriptor
func TestProvenanceCanonicalClaimDescriptorReproducesRustLeaf(t *testing.T) {
// A claim leaf commits subject, authority and evidence refs, not just a
// value. REVIEW-02 §7.2 widened the descriptor precisely so a disclosure
// cannot present semantics the leaf never committed; building the older
// shape here would break every cross-language disclosure.
vector := loadProvenanceVector(t, "provenance-canonical-claim-descriptor-valid")
digest, err := ProvenanceCommitmentDigest(
vector["domain"].(string),
vector["descriptor"],
vector["randomizer"].(string),
)
if err != nil {
t.Fatalf("commit: %v", err)
}
if digest != vector["expected_digest"].(string) {
t.Fatalf("claim leaf mismatch:\n got %s\nwant %s", digest, vector["expected_digest"])
}
}
func TestProvenanceClaimBackingChangesTheLeaf(t *testing.T) {
// Two claims that read the same but rest on different backing must not share
// a leaf: sharing one would let a claim attested by one provider be presented
// as attested by another, which no later check catches.
for _, name := range []string{
"provenance-canonical-claim-descriptor-authority-mutation",
"provenance-canonical-claim-descriptor-evidence-ref-mutation",
} {
vector := loadProvenanceVector(t, name)
digest, err := ProvenanceCommitmentDigest(
vector["domain"].(string),
vector["descriptor"],
vector["randomizer"].(string),
)
if err != nil {
t.Fatalf("%s: commit: %v", name, err)
}
if digest != vector["expected_digest"].(string) {
t.Fatalf("%s: digest mismatch:\n got %s\nwant %s", name, digest, vector["expected_digest"])
}
if digest == vector["must_differ_from"].(string) {
t.Fatalf("%s: leaf collided with the unmutated claim", name)
}
}
}
// ------------------------------------------------------ malformed capsule trees
func TestProvenanceMalformedCapsuleTreeIsRefused(t *testing.T) {
// The duplicate-role case is the one that needs OrderedTopLeafDigests:
// CapsuleRoot receives digests, and by then the role is gone, so a tree
// carrying one role twice and another not at all folds to a root it accepts.
vector := loadProvenanceVector(t, "provenance-capsule-root-invalid-leaf")
valid := loadProvenanceVector(t, "provenance-capsule-root-valid")
digest := strings.Repeat("aa", 32)
randomizer := strings.Repeat("11", 32)
commitments := map[string]string{}
randomizers := map[string]string{}
for _, role := range TopLeafRoles {
commitments[role] = digest
randomizers[role] = randomizer
}
reasons := map[string]struct{}{}
cases := vector["cases"].([]any)
for _, raw := range cases {
c := raw.(map[string]any)
switch {
case c["omit_role"] != nil:
broken := map[string]string{}
for role, value := range commitments {
if role != c["omit_role"].(string) {
broken[role] = value
}
}
if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil {
t.Fatalf("a tree missing %v was accepted", c["omit_role"])
}
case c["duplicate_role"] != nil:
role := c["duplicate_role"].(string)
dropped := ""
for _, candidate := range TopLeafRoles {
if candidate != role {
dropped = candidate
break
}
}
broken := map[string]string{}
for r, value := range commitments {
if r != dropped {
broken[r] = value
}
}
broken[role+"_again"] = digest
if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil {
t.Fatalf("a tree carrying %s twice was accepted", role)
}
case c["malformed_commitment"] != nil:
if _, err := TopLeafDigest("claims_root", c["malformed_commitment"].(string), randomizer); err == nil {
t.Fatal("a malformed commitment was accepted")
}
case c["empty_subtree"] != nil:
if _, err := SubtreeMerkleRoot(c["empty_subtree"].(string), nil); err == nil {
t.Fatalf("an empty %v tree was accepted", c["empty_subtree"])
}
case c["duplicate_leaf_id"] != nil:
leaves := leafInputs(t, valid["claims"])
repeated := leaves[0]
repeated.LeafID = c["duplicate_leaf_id"].(string)
if _, err := OrderSubtreeLeaves([]SubtreeLeafInput{repeated, repeated}); err == nil {
t.Fatal("a duplicate leaf id was accepted")
}
default:
t.Fatalf("unhandled refusal case: %v", c)
}
reasons[c["expected_error"].(string)] = struct{}{}
}
if len(reasons) != len(cases) {
t.Fatalf("each case must fail for its own reason: %d reasons for %d cases", len(reasons), len(cases))
}
}
func TestProvenanceSafeAssemblyAcceptsAWellFormedTree(t *testing.T) {
// The refusals above must not be a function that refuses everything.
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
claimsRoot, _ := buildSubtree(t, "claims", vector["claims"])
evidenceRoot, _ := buildSubtree(t, "evidence", vector["evidence"])
policyRoot, _ := buildSubtree(t, "policy_results", vector["policy_results"])
commitments := map[string]string{
"subject_commitment": vector["subject_commitment"].(string),
"claims_root": claimsRoot,
"evidence_root": evidenceRoot,
"policy_results_root": policyRoot,
"attestation_commitment": vector["attestation_commitment"].(string),
"vault_identity_commitment": vector["vault_identity_commitment"].(string),
}
randomizers := map[string]string{}
for role, value := range vector["top_randomizers"].(map[string]any) {
randomizers[role] = value.(string)
}
digests, err := OrderedTopLeafDigests(commitments, randomizers)
if err != nil {
t.Fatalf("assemble: %v", err)
}
root, err := CapsuleRoot(digests)
if err != nil {
t.Fatalf("capsule root: %v", err)
}
expected := vector["expected"].(map[string]any)
if root != expected["capsule_root"].(string) {
t.Fatalf("capsule root mismatch:\n got %s\nwant %s", root, expected["capsule_root"])
}
}
// ------------------------------------------------------ bundle provenance tree
func bundleLeaves(t *testing.T, raw any) []BundleLeaf {
t.Helper()
encoded, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal leaves: %v", err)
}
var leaves []BundleLeaf
if err := json.Unmarshal(encoded, &leaves); err != nil {
t.Fatalf("unmarshal leaves: %v", err)
}
return leaves
}
func bundleInclusion(t *testing.T, raw any) BundleInclusionProof {
t.Helper()
encoded, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal inclusion: %v", err)
}
var proof BundleInclusionProof
if err := json.Unmarshal(encoded, &proof); err != nil {
t.Fatalf("unmarshal inclusion: %v", err)
}
return proof
}
func verifyBundleInclusion(t *testing.T, proof BundleInclusionProof) bool {
t.Helper()
ok, err := VerifyBundleProvenanceInclusion(proof.ProvenanceRoot, proof.Leaf, proof.Steps, proof.LeafCount)
if err != nil {
t.Fatalf("verify inclusion: %v", err)
}
return ok
}
func TestBundleTreeReproducesRustRoot(t *testing.T) {
vector := loadProvenanceVector(t, "bundle-tree-valid")
expected := vector["expected"].(map[string]any)
leaves := bundleLeaves(t, vector["leaves"])
for index, leaf := range leaves {
digest, err := BundleProvenanceLeaf(leaf)
if err != nil {
t.Fatalf("leaf %d: %v", index, err)
}
if digest != expected["leaf_digests"].([]any)[index] {
t.Fatalf("leaf %d digest mismatch", index)
}
}
tree, err := BundleProvenanceRoot(leaves)
if err != nil {
t.Fatalf("root: %v", err)
}
if tree.LeafCount != 5 || tree.MerkleRoot != expected["merkle_root"] || tree.ProvenanceRoot != expected["provenance_root"] {
t.Fatalf("tree mismatch: %+v", tree)
}
reversed := make([]BundleLeaf, 0, len(leaves))
for index := len(leaves) - 1; index >= 0; index-- {
reversed = append(reversed, leaves[index])
}
shuffled, err := BundleProvenanceRoot(reversed)
if err != nil || shuffled.ProvenanceRoot != tree.ProvenanceRoot {
t.Fatalf("caller order must not change the root: %v", err)
}
}
func TestBundleTreeSingleLeafIsItsOwnMerkleRoot(t *testing.T) {
vector := loadProvenanceVector(t, "bundle-tree-single-leaf")
expected := vector["expected"].(map[string]any)
tree, err := BundleProvenanceRoot(bundleLeaves(t, vector["leaves"]))
if err != nil {
t.Fatalf("root: %v", err)
}
if tree.MerkleRoot != tree.OrderedLeafDigests[0] || tree.MerkleRoot != expected["merkle_root"] {
t.Fatalf("single leaf must be its own merkle root")
}
if tree.ProvenanceRoot != expected["provenance_root"] {
t.Fatalf("provenance root mismatch")
}
proof := bundleInclusion(t, vector["inclusion"])
if len(proof.Steps) != 0 || verifyBundleInclusion(t, proof) != vector["expected_verified"].(bool) {
t.Fatalf("single-leaf inclusion must verify with no steps")
}
}
func TestBundleTreeEveryLeafProvesToTheRoot(t *testing.T) {
vector := loadProvenanceVector(t, "bundle-tree-inclusion-valid")
leaves := bundleLeaves(t, loadProvenanceVector(t, "bundle-tree-valid")["leaves"])
for _, raw := range vector["cases"].([]any) {
c := raw.(map[string]any)
proof := bundleInclusion(t, c["inclusion"])
if !c["expected_verified"].(bool) || !verifyBundleInclusion(t, proof) {
t.Fatalf("seq_no %d must verify", proof.Leaf.SeqNo)
}
// The Go prover reproduces the Rust proof exactly, including the
// promoted leaf that emits no step for its odd level.
produced, err := BundleProvenanceProof(leaves, proof.Leaf.SeqNo)
if err != nil {
t.Fatalf("prove %d: %v", proof.Leaf.SeqNo, err)
}
want, _ := json.Marshal(proof)
got, _ := json.Marshal(produced)
if string(want) != string(got) {
t.Fatalf("proof for seq_no %d differs from Rust:\n%s\n%s", proof.Leaf.SeqNo, want, got)
}
}
}
func TestBundleTreeRefusesTheFrozenNegatives(t *testing.T) {
for _, name := range []string{
"bundle-tree-inclusion-wrong-root",
"bundle-tree-inclusion-wrong-seq-no",
"bundle-tree-inclusion-wrong-installation",
"bundle-tree-inclusion-wrong-capsule-root",
"bundle-tree-wrong-domain",
} {
vector := loadProvenanceVector(t, name)
if vector["expected_verified"].(bool) {
t.Fatalf("%s should be a negative vector", name)
}
if verifyBundleInclusion(t, bundleInclusion(t, vector["inclusion"])) {
t.Fatalf("%s verified but must not", name)
}
}
}
func TestBundleTreeLeafDomainIsLoadBearing(t *testing.T) {
vector := loadProvenanceVector(t, "bundle-tree-wrong-domain")
leaf := bundleLeaves(t, []any{vector["leaf"]})[0]
digest, err := BundleProvenanceLeaf(leaf)
if err != nil {
t.Fatalf("leaf: %v", err)
}
if digest != vector["bundle_tree_leaf_digest"] || digest == vector["wrong_domain_leaf_digest"] {
t.Fatalf("leaf digest must be domain-separated")
}
}
func TestBundleTreePromotesAndRefusesADuplicateSeqNo(t *testing.T) {
vector := loadProvenanceVector(t, "bundle-tree-leaf-duplicated-not-promoted")
leaves := bundleLeaves(t, vector["leaves"])
if _, err := BundleProvenanceRoot(leaves); err == nil || !strings.Contains(err.Error(), "duplicate leaf id") {
t.Fatalf("a repeated seq_no must be refused, got %v", err)
}
five, err := BundleProvenanceRoot(leaves[:5])
if err != nil {
t.Fatalf("root: %v", err)
}
if five.MerkleRoot != vector["promoted_merkle_root"] || five.MerkleRoot == vector["duplicated_fold_merkle_root"] {
t.Fatalf("odd leaf must be promoted, never duplicated")
}
}
func TestBundleTreeRefusesASignedL3AndAnEmptyBundle(t *testing.T) {
leaf := bundleLeaves(t, loadProvenanceVector(t, "bundle-tree-valid")["leaves"])[0]
leaf.VaultAssurance = "L3"
if _, err := BundleProvenanceLeaf(leaf); err == nil {
t.Fatalf("a leaf claiming L3 must be malformed")
}
if _, err := BundleProvenanceRoot(nil); err == nil {
t.Fatalf("an empty bundle has no tree")
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
package attesto
const (
SDKVersion = "0.3.0"
SDKVersion = "0.5.0"
DefaultBaseURL = "https://verify.attesto.eu"
ProofstreamProtocol = "ATTESTO-PROOFSTREAM-001"
ProtocolVersionAlpha = "0.1-alpha"
+8
View File
@@ -0,0 +1,8 @@
// A separate module on purpose: go.attesto.eu/sdk has no dependencies at all,
// and a consumer who never opens a private numeric should not inherit a curve
// library to keep it that way.
module go.attesto.eu/sdk/zk
go 1.24
require github.com/gtank/ristretto255 v0.1.2
+2
View File
@@ -0,0 +1,2 @@
github.com/gtank/ristretto255 v0.1.2 h1:JEqUCPA1NvLq5DwYtuzigd7ss8fwbYay9fi4/5uMzcc=
github.com/gtank/ristretto255 v0.1.2/go.mod h1:Ph5OpO6c7xKUGROZfWVLiJf9icMDwUeIvY4OmlYW69o=
+113
View File
@@ -0,0 +1,113 @@
// Package zk verifies Pedersen openings for Attesto private numeric claims.
//
// It is a separate module from go.attesto.eu/sdk because that package carries no
// dependencies at all, and most verification is SHA-256 and Merkle work. A
// consumer who never opens a private numeric should not inherit a curve library.
//
// This package does not verify range proofs. That needs a full bulletproofs
// implementation, not curve arithmetic, and remains the Rust core's job.
package zk
import (
"encoding/hex"
"fmt"
"math/big"
"github.com/gtank/ristretto255"
)
// The frozen v1 generator pair, from docs/protocol/zk-generator-registry.md. The
// registry defines what Attesto means by these; a dependency's word "default" is
// not the protocol definition, so they are pinned here.
const (
GeneratorSetID = "attesto-ristretto255-pedersen-v1"
GeneratorBHex = "e2f2ae0a6abc4e71a884a961c500515f58e30b6aa582dd8db6a65945e08d2d76"
GeneratorHHex = "8c9240b456a9e6dc65c377a1048d745f94a08cdb7f44cbcd7b46f34048871134"
)
func decodePoint(value string) (*ristretto255.Element, error) {
raw, err := hex.DecodeString(value)
if err != nil {
return nil, fmt.Errorf("point is not hex")
}
element := ristretto255.NewElement()
if err := element.Decode(raw); err != nil {
return nil, fmt.Errorf("point is not a valid ristretto element")
}
return element, nil
}
func scalarFromUint(value uint64) *ristretto255.Scalar {
// Canonical 32-byte little-endian, which is what the core commits under.
var wide [64]byte
big.NewInt(0).SetUint64(value).FillBytes(wide[:8])
// FillBytes writes big-endian into the slice; reverse into little-endian.
var canonical [32]byte
for index := 0; index < 8; index++ {
canonical[index] = wide[7-index]
}
scalar := ristretto255.NewScalar()
// SetCanonicalBytes cannot fail for a value below 2^64.
if err := scalar.Decode(canonical[:]); err != nil {
panic("a value below 2^64 is always a canonical scalar: " + err.Error())
}
return scalar
}
// VerifyOpening recomputes v·B + r·H and requires it to equal the committed C,
// byte for byte.
//
// This is the last link of the exact-opening chain: v + r -> C -> claim leaf ->
// capsule root. The descriptor must already have opened its claim leaf; only
// then is the commitment checked here the committed one. Verifying against a
// descriptor a holder merely supplied would let a matching pair be fabricated
// whole.
func VerifyOpening(descriptor map[string]any, encodedValue uint64, blindingScalar string) (bool, error) {
pedersen, _ := descriptor["pedersen"].(map[string]any)
if pedersen["generator_set_id"] != GeneratorSetID {
return false, fmt.Errorf("descriptor names a different generator set")
}
committed, ok := pedersen["commitment"].(string)
if !ok || len(committed) != 64 {
return false, fmt.Errorf("descriptor carries no commitment to open")
}
// A value outside the declared domain cannot be what was committed, whatever
// the blinding. Checking it here rather than letting the commitment simply
// fail to match means the answer names the real reason, and matches the Rust
// core.
if encoding, ok := descriptor["encoding"].(map[string]any); ok {
minimum, hasMin := encoding["semantic_min_encoded"].(float64)
maximum, hasMax := encoding["semantic_max_encoded"].(float64)
if hasMin && hasMax {
if float64(encodedValue) < minimum || float64(encodedValue) > maximum {
return false, nil
}
}
}
raw, err := hex.DecodeString(blindingScalar)
if err != nil || len(raw) != 32 {
return false, fmt.Errorf("opening blinding is not 32 hex-encoded bytes")
}
blinding := ristretto255.NewScalar()
if err := blinding.Decode(raw); err != nil {
// A non-canonical encoding decodes to the same scalar as a canonical one
// and would let two opening records open the same commitment.
return false, fmt.Errorf("opening blinding is not a canonical scalar")
}
base, err := decodePoint(GeneratorBHex)
if err != nil {
return false, err
}
blindingBase, err := decodePoint(GeneratorHHex)
if err != nil {
return false, err
}
value := ristretto255.NewElement().ScalarMult(scalarFromUint(encodedValue), base)
mask := ristretto255.NewElement().ScalarMult(blinding, blindingBase)
recomputed := ristretto255.NewElement().Add(value, mask)
return hex.EncodeToString(recomputed.Encode(nil)) == committed, nil
}
+206
View File
@@ -0,0 +1,206 @@
package zk
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// Go parity on the Pedersen opening vectors.
//
// These were a declared boundary: verifying them needs ristretto255 scalar
// arithmetic the dependency-free SDK does not carry. This module carries it, so
// a consumer who needs exact-opening verification can have it without imposing a
// curve library on everyone else.
func loadVector(t *testing.T, name string) map[string]any {
t.Helper()
path := filepath.Join("..", "..", "..", "golden-vectors", "provenance-v0.1-dev", name+".json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
var vector map[string]any
if err := json.Unmarshal(raw, &vector); err != nil {
t.Fatalf("parse %s: %v", name, err)
}
return vector
}
func openingFrom(t *testing.T, vector map[string]any) (map[string]any, uint64, string) {
t.Helper()
descriptor := vector["descriptor"].(map[string]any)
value := uint64(vector["encoded_value"].(float64))
return descriptor, value, vector["blinding_scalar"].(string)
}
func TestGeneratorPairMatchesTheFrozenRegistry(t *testing.T) {
// Constants that drifted would commit under a different pair than the
// protocol declares, and every commitment would be unopenable elsewhere.
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "protocol", "zk-generator-registry.md"))
if err != nil {
t.Fatalf("read registry: %v", err)
}
registry := string(raw)
for _, pinned := range []string{GeneratorSetID, GeneratorBHex, GeneratorHHex} {
if !strings.Contains(registry, pinned) {
t.Fatalf("registry no longer carries %s", pinned)
}
}
}
func TestAValidOpeningReproducesTheRustCommitment(t *testing.T) {
vector := loadVector(t, "provenance-private-numeric-opening-valid")
descriptor, value, blinding := openingFrom(t, vector)
ok, err := VerifyOpening(descriptor, value, blinding)
if err != nil {
t.Fatalf("verify: %v", err)
}
if ok != vector["expected_valid"].(bool) {
t.Fatalf("got %v want %v", ok, vector["expected_valid"])
}
}
func TestAWrongOpeningDoesNotReproduceTheCommitment(t *testing.T) {
for _, name := range []string{
"provenance-private-numeric-opening-wrong-value",
"provenance-private-numeric-opening-wrong-blinding",
} {
vector := loadVector(t, name)
descriptor, value, blinding := openingFrom(t, vector)
ok, err := VerifyOpening(descriptor, value, blinding)
if err != nil {
t.Fatalf("%s: verify: %v", name, err)
}
if ok != vector["expected_valid"].(bool) {
t.Fatalf("%s: got %v want %v", name, ok, vector["expected_valid"])
}
}
}
func TestTheCommittedCommitmentIsWhatAValidOpeningProduces(t *testing.T) {
// Reads the commitment back out, so a check that always returned true fails.
vector := loadVector(t, "provenance-private-numeric-commitment-valid")
descriptor := vector["descriptor"].(map[string]any)
value := uint64(vector["expected_encoded_value"].(float64))
blinding := vector["blinding_scalar"].(string)
ok, err := VerifyOpening(descriptor, value, blinding)
if err != nil || !ok {
t.Fatalf("a valid opening did not verify: ok=%v err=%v", ok, err)
}
pedersen := descriptor["pedersen"].(map[string]any)
pedersen["commitment"] = strings.Repeat("00", 32)
ok, err = VerifyOpening(descriptor, value, blinding)
if err != nil {
t.Fatalf("verify: %v", err)
}
if ok {
t.Fatal("a tampered commitment still verified")
}
}
func TestTheSameMeasurementUnderTwoBlindingsIsUnlinkable(t *testing.T) {
vector := loadVector(t, "provenance-private-numeric-commitment-randomized")
if vector["first_commitment"] == vector["second_commitment"] {
t.Fatal("two blindings produced the same commitment")
}
}
func TestADescriptorNamingAnotherGeneratorSetIsRefused(t *testing.T) {
// "rejected" is not "invalid": the input is refused before any check runs.
vector := loadVector(t, "provenance-private-numeric-wrong-generator-set")
descriptor, value, blinding := openingFrom(t, vector)
if _, err := VerifyOpening(descriptor, value, blinding); err == nil {
t.Fatal("a foreign generator set was accepted")
}
}
func TestAWidenedEncodingDomainIsNotTheCurveChecksBusiness(t *testing.T) {
// A claim about semantics is refused by whoever validates the profile. The
// commitment still opens, and saying otherwise would blame the wrong layer.
vector := loadVector(t, "provenance-private-numeric-encoding-mismatch")
descriptor, value, blinding := openingFrom(t, vector)
ok, err := VerifyOpening(descriptor, value, blinding)
if err != nil || !ok {
t.Fatalf("the commitment should still open: ok=%v err=%v", ok, err)
}
}
func TestAValueOutsideTheDeclaredDomainCannotOpen(t *testing.T) {
// "invalid", not "rejected": the check ran and answered no. The commitment
// would fail to match anyway, but for the wrong reason.
vector := loadVector(t, "provenance-private-numeric-semantic-bound-invalid")
descriptor, value, blinding := openingFrom(t, vector)
ok, err := VerifyOpening(descriptor, value, blinding)
if err != nil {
t.Fatalf("verify: %v", err)
}
if ok != vector["expected_valid"].(bool) {
t.Fatalf("got %v want %v", ok, vector["expected_valid"])
}
}
func TestAMalformedOpeningIsRefused(t *testing.T) {
vector := loadVector(t, "provenance-private-numeric-opening-valid")
descriptor, value, _ := openingFrom(t, vector)
for _, blinding := range []string{"nothex", strings.Repeat("ab", 31), "", strings.Repeat("ff", 32)} {
if _, err := VerifyOpening(descriptor, value, blinding); err == nil {
t.Fatalf("a malformed blinding %q was accepted", blinding)
}
}
}
// TestAMeasurementOfZeroOpensItsCommitment pins the case that split the SDKs.
//
// Zero is legal wherever semantic_min_encoded is 0: a detector reporting exactly
// 0.0 produces one, and C = 0*B + r*H is an ordinary commitment to it. Two of
// the three clients got it wrong in the same way -- libsodium and noble both
// refuse a scalar multiplication whose result is the identity, which is right
// for a key exchange and wrong here, and both SDKs read the refusal as an
// invalid opening. This library accepts the zero scalar and was correct.
//
// That is the argument for a shared corpus rather than per-language tests: two
// implementations agreeing is not evidence, and the one that matched the Rust
// core was the odd one out.
func TestAMeasurementOfZeroOpensItsCommitment(t *testing.T) {
vector := loadVector(t, "provenance-private-numeric-opening-zero-value")
descriptor, value, blinding := openingFrom(t, vector)
if value != 0 {
t.Fatalf("vector is not the zero case: got %d", value)
}
ok, err := VerifyOpening(descriptor, value, blinding)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !ok {
t.Fatal("a measurement of zero must open its commitment")
}
}
// TestZeroIsNotASkeletonKey guards the shape of the fix the other SDKs needed.
func TestZeroIsNotASkeletonKey(t *testing.T) {
zero := loadVector(t, "provenance-private-numeric-opening-zero-value")
other := loadVector(t, "provenance-private-numeric-opening-valid")
zeroDescriptor, _, zeroBlinding := openingFrom(t, zero)
otherDescriptor, _, otherBlinding := openingFrom(t, other)
for _, probe := range []struct {
name string
descriptor map[string]any
value uint64
blinding string
}{
{"zero against another commitment", otherDescriptor, 0, otherBlinding},
{"non-zero against the zero commitment", zeroDescriptor, 1, zeroBlinding},
{"zero blinding against a real commitment", zeroDescriptor, 0, strings.Repeat("00", 32)},
} {
ok, err := VerifyOpening(probe.descriptor, probe.value, probe.blinding)
if err == nil && ok {
t.Fatalf("%s: opened a commitment it must not", probe.name)
}
}
}
+222
View File
@@ -0,0 +1,222 @@
package attesto
// Go parity against the ATTESTO-ZK-RANGE-001 result corpus.
//
// The cryptography of a range proof is not checked here and cannot be: this SDK
// carries no ristretto255 arithmetic. What is checked is what an SDK can get
// wrong on its own — reporting the issuer's word as its own finding, or handing
// a consumer an object shaped like a verdict.
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func zkRangeVectorDir(t *testing.T) string {
t.Helper()
dir := filepath.Join("..", "..", "golden-vectors", "zk-range-v0.1-dev")
if _, err := os.Stat(dir); err != nil {
t.Fatalf("no zk-range vectors at %s: %v", dir, err)
}
return dir
}
func loadZKRangeVector(t *testing.T, name string) map[string]any {
t.Helper()
raw, err := os.ReadFile(filepath.Join(zkRangeVectorDir(t), name+".json"))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
var vector map[string]any
if err := json.Unmarshal(raw, &vector); err != nil {
t.Fatalf("parse %s: %v", name, err)
}
return vector
}
func TestZKRangeCorpusIsPresentAndTyped(t *testing.T) {
entries, err := filepath.Glob(filepath.Join(zkRangeVectorDir(t), "*.json"))
if err != nil || len(entries) == 0 {
t.Fatalf("no zk-range vectors: %v", err)
}
for _, entry := range entries {
raw, err := os.ReadFile(entry)
if err != nil {
t.Fatalf("read %s: %v", entry, err)
}
var vector map[string]any
if err := json.Unmarshal(raw, &vector); err != nil {
t.Fatalf("parse %s: %v", entry, err)
}
if vector["protocol"] != ZKRangeProtocol {
t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"])
}
if vector["requires"] == nil {
t.Fatalf("%s: does not declare what it requires", entry)
}
switch vector["expectation"] {
case "valid", "invalid", "rejected", "differs":
default:
t.Fatalf("%s: bad expectation %v", entry, vector["expectation"])
}
}
}
func TestZKRangeClientWithoutCurveArithmeticSaysSo(t *testing.T) {
// The whole point. This SDK cannot verify the proof and reports that; the
// issuer's own verification block is kept under a separate key so a reader
// can tell a claim apart from a check.
vector := loadZKRangeVector(t, "zk-range-result-valid")
result := vector["result"].(map[string]any)
report, err := InspectPredicateResult(result, nil)
if err != nil {
t.Fatalf("inspect: %v", err)
}
if report.VerifiedHere["zk_predicate"] != "not_checked" {
t.Fatalf("this client cannot verify a proof but reported %q", report.VerifiedHere["zk_predicate"])
}
expected := vector["expected_verified_here"].(map[string]any)
for key, want := range expected {
if report.VerifiedHere[key] != want.(string) {
t.Fatalf("verified_here[%s]: got %q want %v", key, report.VerifiedHere[key], want)
}
}
if report.ReportedByIssuer["zk_predicate"] != "verified" {
t.Fatalf("the issuer's own claim was not carried through separately")
}
}
func TestZKRangeInclusionTheClientCheckedIsReported(t *testing.T) {
// Not everything is out of reach: two-hop inclusion is SHA-256.
vector := loadZKRangeVector(t, "zk-range-result-valid")
result := vector["result"].(map[string]any)
for _, testCase := range []struct {
checked bool
want string
}{{true, "verified"}, {false, "failed"}} {
checked := testCase.checked
report, err := InspectPredicateResult(result, &checked)
if err != nil {
t.Fatalf("inspect: %v", err)
}
if report.VerifiedHere["capsule_inclusion"] != testCase.want {
t.Fatalf("capsule_inclusion: got %q want %q", report.VerifiedHere["capsule_inclusion"], testCase.want)
}
}
}
func TestZKRangeResultCarriesTheThreeNonClaims(t *testing.T) {
vector := loadZKRangeVector(t, "zk-range-result-valid")
report, err := InspectPredicateResult(vector["result"].(map[string]any), nil)
if err != nil {
t.Fatalf("inspect: %v", err)
}
if len(report.NotClaimed) != len(RequiredNonClaims) {
t.Fatalf("expected %d non-claims, got %d", len(RequiredNonClaims), len(report.NotClaimed))
}
for index, required := range RequiredNonClaims {
if report.NotClaimed[index]["id"] != required {
t.Fatalf("non-claim %d: got %v want %s", index, report.NotClaimed[index]["id"], required)
}
}
}
func TestZKRangeMisleadingResultsAreRefused(t *testing.T) {
for _, name := range []string{
"zk-range-result-missing-non-claim",
"zk-range-result-verdict-field",
"zk-range-result-nested-verdict-field",
"zk-range-result-unbound",
"zk-range-result-unsupported-version",
} {
vector := loadZKRangeVector(t, name)
if vector["expectation"] != "rejected" {
t.Fatalf("%s: expected a rejected vector", name)
}
if _, err := InspectPredicateResult(vector["result"].(map[string]any), nil); err == nil {
t.Fatalf("%s was accepted", name)
}
}
}
func TestZKRangeRefusalsAreNotBlanket(t *testing.T) {
// The refusals above must not be a function that refuses everything.
vector := loadZKRangeVector(t, "zk-range-result-valid")
if _, err := InspectPredicateResult(vector["result"].(map[string]any), nil); err != nil {
t.Fatalf("a well-formed result was refused: %v", err)
}
}
// ------------------------------------------------------- statement and width
func TestZKRangeStatementCarriesExactlyTheTranscriptBoundFields(t *testing.T) {
// Every field is folded into the proof transcript. An extra one would bind to
// nothing; a missing one would change the challenges. So the set is exact.
vector := loadZKRangeVector(t, "zk-range-statement-valid")
statement := vector["statement"].(map[string]any)
expected := vector["expected_fields"].([]any)
if len(statement) != len(expected) {
t.Fatalf("statement carries %d fields, corpus lists %d", len(statement), len(expected))
}
for _, field := range expected {
if _, ok := statement[field.(string)]; !ok {
t.Fatalf("statement is missing %v", field)
}
}
width, err := ValidateRangeStatement(statement)
if err != nil {
t.Fatalf("validate: %v", err)
}
if float64(width) != vector["expected_width"].(float64) {
t.Fatalf("width: got %d want %v", width, vector["expected_width"])
}
}
func TestZKRangeWidthIsDerivedFromThePublicBounds(t *testing.T) {
// Pinned across languages because a divergent width is invisible: a client
// picking a different one produces proofs nobody else can verify, and the
// symptom looks like a broken proof rather than a divergent rule.
vector := loadZKRangeVector(t, "zk-range-width-selection")
for _, raw := range vector["cases"].([]any) {
testCase := raw.(map[string]any)
lower := uint64(testCase["lower_bound"].(float64))
upper := uint64(testCase["upper_bound"].(float64))
width, err := ZKRangeWidth(lower, upper)
if err != nil {
t.Fatalf("[%d, %d]: %v", lower, upper, err)
}
if float64(width) != testCase["expected_width"].(float64) {
t.Fatalf("[%d, %d]: got %d want %v", lower, upper, width, testCase["expected_width"])
}
}
}
func TestZKRangeDishonestStatementsAreRefused(t *testing.T) {
for _, name := range []string{
"zk-range-statement-float-bound",
"zk-range-statement-inverted",
"zk-range-statement-unknown-field",
"zk-range-statement-empty-nonce",
} {
vector := loadZKRangeVector(t, name)
if vector["expectation"] != "rejected" {
t.Fatalf("%s: expected a rejected vector", name)
}
if _, err := ValidateRangeStatement(vector["statement"].(map[string]any)); err == nil {
t.Fatalf("%s was accepted", name)
}
}
}
func TestZKRangeStatementRefusalsAreNotBlanket(t *testing.T) {
vector := loadZKRangeVector(t, "zk-range-statement-valid")
if _, err := ValidateRangeStatement(vector["statement"].(map[string]any)); err != nil {
t.Fatalf("a well-formed statement was refused: %v", err)
}
}