19 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
22 changed files with 4066 additions and 86 deletions
+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)
}
}
+47 -21
View File
@@ -2,7 +2,7 @@ package main
// [D.5] `attesto connector init <slug>` — scaffold a marketplace-ready
// connector: a v2 manifest that passes connectorkit validation locally, a
// signed-webhook handler stub built on the P1.4 verification helper, and a
// 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.
@@ -22,7 +22,13 @@ import (
var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`)
func connectorManifestTemplate(slug, name, category string) connectorkit.Manifest {
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,
@@ -33,9 +39,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
Description: fmt.Sprintf(
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"},
Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug},
Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"},
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",
},
@@ -53,7 +59,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
Provider: map[string]any{
"id": slug,
"name": name,
"websiteUrl": "https://example.com",
"websiteUrl": providerURL,
},
Auth: map[string]any{
"mode": "signed-webhook",
@@ -97,12 +103,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
"metadata", "validateConfig", "testConnection", "sync",
"handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke",
},
// A scaffold cannot honestly claim a green assurance canary; this
// stays "pending" (the one expected validation finding) until the
// connector has real canary evidence.
"canary": map[string]any{
"status": "pending",
"ref": "CHANGE ME: assurance canary evidence ref",
"status": canaryStatus,
"ref": canaryRef,
},
},
InstallRequirements: map[string]any{
@@ -115,7 +118,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
}
}
const webhookHandlerStub = `"""Signed-webhook handler stub for the %s connector.
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
@@ -134,16 +137,15 @@ def handle(headers: dict[str, str], body: bytes) -> dict:
):
raise PermissionError("invalid webhook signature or stale timestamp")
# The payload is now authentic: turn it into a proofstream event here.
return {"ok": True}
return {"ok": True, "authenticatedPayloadBytes": len(body)}
`
const connectorReadmeStub = `# %s
const connectorReadmeTemplate = `# %s
Scaffolded by ` + "`attesto connector init`" + `.
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
event types — search for CHANGE ME).
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:
@@ -159,6 +161,12 @@ func (a *app) connectorInit(args []string) error {
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 := ""
@@ -198,12 +206,31 @@ func (a *app) connectorInit(args []string) error {
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)
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.
@@ -230,11 +257,11 @@ func (a *app) connectorInit(args []string) error {
if err := os.WriteFile(filepath.Join(outDir, "attesto.connector.json"), append(raw, '\n'), 0o644); err != nil {
return err
}
handler := fmt.Sprintf(webhookHandlerStub, connectorName)
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(connectorReadmeStub, connectorName, outDir)
readme := fmt.Sprintf(connectorReadmeTemplate, connectorName, outDir)
if err := os.WriteFile(filepath.Join(outDir, "README.md"), []byte(readme), 0o644); err != nil {
return err
}
@@ -242,7 +269,6 @@ func (a *app) connectorInit(args []string) error {
"created": outDir,
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
"validation": result,
"nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " +
"assurance canary, re-run with --validate-only until OK",
"nextSteps": "implement runtime methods, attach real canary evidence, re-run with --validate-only until OK",
})
}
+38 -6
View File
@@ -1,7 +1,7 @@
package main
// [D.5] connector init scaffolds a manifest whose ONLY validation finding is
// the pending canary, and the generated webhook stub calls the real P1.4
// the pending canary, and the generated webhook starter calls the real P1.4
// helper with its actual signature.
import (
@@ -18,13 +18,26 @@ import (
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
dir := filepath.Join(t.TempDir(), "my-crm")
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
if err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}); err != nil {
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)
@@ -35,15 +48,34 @@ func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
t.Fatalf("unexpected finding: %+v", finding)
}
}
stub, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
handler, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(stub), "from attesto.webhooks import verify_webhook") {
t.Fatal("stub does not use the P1.4 helper")
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}); err == nil {
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)
}
}
+67 -6
View File
@@ -22,7 +22,7 @@ import (
"go.attesto.eu/sdk/connectorkit"
)
const cliVersion = "0.4.0"
const cliVersion = "0.5.0"
var supportedVerifyKindNames = []string{
"receipt",
@@ -476,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"))
@@ -1050,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
@@ -1065,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})
@@ -1780,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",
+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)
}
}
}
+58 -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
}
return closeErr
}
_ = os.Chmod(tmpName, 0o600)
_ = os.Rename(tmpName, s.path)
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
@@ -160,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
}
+26
View File
@@ -81,6 +81,32 @@ func TestFileHeadStorePersistsAndIs0600(t *testing.T) {
}
}
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.
+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.4.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)
}
}