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>
937 lines
31 KiB
Go
937 lines
31 KiB
Go
package attesto
|
|
|
|
// Attesto 3 provenance verification (ATTESTO-PROVENANCE-001).
|
|
//
|
|
// Rust (edge/) is normative: it constructs commitments and capsule roots. This
|
|
// file is a verification client — it re-derives what the edge core produced and
|
|
// checks it. It cannot generate a randomizer, because outside the Local Vault
|
|
// there is nothing legitimate to commit.
|
|
//
|
|
// Canonicalization and domain hashing come from proofstream.go rather than a
|
|
// second implementation: the provenance lane hashes bytes under the same frozen
|
|
// ATTESTO-CANONICAL-JSON-001 rules as the rest of Attesto.
|
|
//
|
|
// Conformance is defined by golden-vectors/provenance-v0.1-dev/.
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ProvenanceProtocol = "ATTESTO-PROVENANCE-001"
|
|
ProvenanceProtocolVersion = "0.1"
|
|
provenanceRandomizerBytes = 32
|
|
)
|
|
|
|
// ProvenanceDomains is the closed v1 registry. There is deliberately no generic
|
|
// attesto.provenance.v1.commitment fallback: every semantic object has its own
|
|
// domain, and an object without one is a protocol-registry change.
|
|
var ProvenanceDomains = map[string]struct{}{
|
|
"attesto.provenance.v1.asset": {},
|
|
"attesto.provenance.v1.claim": {},
|
|
"attesto.provenance.v1.evidence": {},
|
|
"attesto.provenance.v1.edge": {},
|
|
"attesto.provenance.v1.capsule_leaf": {},
|
|
"attesto.provenance.v1.capsule_node": {},
|
|
"attesto.provenance.v1.capsule_root": {},
|
|
"attesto.provenance.v1.envelope": {},
|
|
"attesto.provenance.v1.provider_result": {},
|
|
"attesto.provenance.v1.policy_result": {},
|
|
"attesto.provenance.v1.disclosure": {},
|
|
"attesto.provenance.v1.migration": {},
|
|
"attesto.provenance.v1.vault_identity": {},
|
|
"attesto.provenance.v1.attesto_mark": {},
|
|
"attesto.provenance.v1.claims_tree": {},
|
|
"attesto.provenance.v1.evidence_tree": {},
|
|
"attesto.provenance.v1.policy_tree": {},
|
|
"attesto.provenance.v1.attestation": {},
|
|
"attesto.disclosure.v2": {},
|
|
"attesto.zk.range.v1.statement": {},
|
|
"attesto.zk.range.v1.transcript": {},
|
|
}
|
|
|
|
// TopLeafRoles is the canonical order of the six typed top-tree leaves. No
|
|
// other leaf exists in v1.
|
|
var TopLeafRoles = [6]string{
|
|
"subject_commitment",
|
|
"claims_root",
|
|
"evidence_root",
|
|
"policy_results_root",
|
|
"attestation_commitment",
|
|
"vault_identity_commitment",
|
|
}
|
|
|
|
var subtreeTreeDomain = map[string]string{
|
|
"claims": "attesto.provenance.v1.claims_tree",
|
|
"evidence": "attesto.provenance.v1.evidence_tree",
|
|
"policy_results": "attesto.provenance.v1.policy_tree",
|
|
}
|
|
|
|
var subtreeTopRole = map[string]string{
|
|
"claims": "claims_root",
|
|
"evidence": "evidence_root",
|
|
"policy_results": "policy_results_root",
|
|
}
|
|
|
|
func assertProvenanceDomain(domain string) error {
|
|
if _, ok := ProvenanceDomains[domain]; !ok {
|
|
return fmt.Errorf("unknown provenance domain: %q; there is no fallback domain", domain)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertRandomizer(randomizer string) error {
|
|
if len(randomizer) != provenanceRandomizerBytes*2 {
|
|
return fmt.Errorf("randomizer must be exactly %d bytes", provenanceRandomizerBytes)
|
|
}
|
|
for _, char := range randomizer {
|
|
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
|
|
return fmt.Errorf("randomizer must be lowercase hex")
|
|
}
|
|
}
|
|
if _, err := hex.DecodeString(randomizer); err != nil {
|
|
return fmt.Errorf("randomizer must be lowercase hex")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertProvenanceDigest(field, digest string) error {
|
|
if len(digest) != 64 {
|
|
return fmt.Errorf("malformed digest in %s: expected 64 lowercase hex characters", field)
|
|
}
|
|
for _, char := range digest {
|
|
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
|
|
return fmt.Errorf("malformed digest in %s: expected 64 lowercase hex characters", field)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProvenanceCommitmentDigest re-derives a randomized commitment. Verification
|
|
// only — the randomizer must already be known, which means the holder was given
|
|
// the opening.
|
|
func ProvenanceCommitmentDigest(domain string, value any, randomizer string) (string, error) {
|
|
if err := assertProvenanceDomain(domain); err != nil {
|
|
return "", err
|
|
}
|
|
if err := assertRandomizer(randomizer); err != nil {
|
|
return "", err
|
|
}
|
|
if err := AssertCommitmentSafeNumbers(value, "$"); err != nil {
|
|
return "", err
|
|
}
|
|
return DomainHashHex(domain, map[string]any{
|
|
"protocol": ProvenanceProtocol,
|
|
"protocol_version": ProvenanceProtocolVersion,
|
|
"randomizer": randomizer,
|
|
"value": value,
|
|
})
|
|
}
|
|
|
|
// VerifyProvenanceCommitment checks in constant time that (value, randomizer)
|
|
// opens expectedDigest.
|
|
func VerifyProvenanceCommitment(domain string, value any, randomizer, expectedDigest string) (bool, error) {
|
|
digest, err := ProvenanceCommitmentDigest(domain, value, randomizer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(digest), []byte(expectedDigest)) == 1, nil
|
|
}
|
|
|
|
func provenanceNode(domain, left, right string) (string, error) {
|
|
return DomainHashHex(domain, map[string]any{
|
|
"kind": "node",
|
|
"left": left,
|
|
"right": right,
|
|
})
|
|
}
|
|
|
|
func provenanceFold(domain string, level []string) (string, error) {
|
|
current := append([]string(nil), level...)
|
|
for len(current) > 1 {
|
|
next := make([]string, 0, (len(current)+1)/2)
|
|
for index := 0; index < len(current); index += 2 {
|
|
if index+1 >= len(current) {
|
|
next = append(next, current[index]) // promote odd node, never duplicate
|
|
continue
|
|
}
|
|
node, err := provenanceNode(domain, current[index], current[index+1])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
next = append(next, node)
|
|
}
|
|
current = next
|
|
}
|
|
return current[0], nil
|
|
}
|
|
|
|
// SubtreeMerkleRoot folds a subtree's ordered leaves into its bare Merkle root.
|
|
func SubtreeMerkleRoot(subtree string, orderedLeaves []string) (string, error) {
|
|
domain, ok := subtreeTreeDomain[subtree]
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown subtree: %q", subtree)
|
|
}
|
|
if len(orderedLeaves) == 0 {
|
|
return "", fmt.Errorf("cannot build an empty %s tree", subtree)
|
|
}
|
|
for index, leaf := range orderedLeaves {
|
|
if err := assertProvenanceDigest(fmt.Sprintf("orderedLeaves[%d]", index), leaf); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return provenanceFold(domain, orderedLeaves)
|
|
}
|
|
|
|
// SubtreeRoot wraps a bare Merkle root in its typed subtree root.
|
|
func SubtreeRoot(subtree, merkleRoot string, leafCount int) (string, error) {
|
|
domain, ok := subtreeTreeDomain[subtree]
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown subtree: %q", subtree)
|
|
}
|
|
if err := assertProvenanceDigest("merkleRoot", merkleRoot); err != nil {
|
|
return "", err
|
|
}
|
|
return DomainHashHex(domain, map[string]any{
|
|
"kind": "root",
|
|
"tree": subtree,
|
|
"leaf_count": leafCount,
|
|
"merkle_root": merkleRoot,
|
|
})
|
|
}
|
|
|
|
// SubtreeLeafInput is one leaf awaiting canonical ordering.
|
|
type SubtreeLeafInput struct {
|
|
LeafRole string `json:"leaf_role"`
|
|
LeafID string `json:"leaf_id"`
|
|
Commitment string `json:"commitment"`
|
|
}
|
|
|
|
// OrderSubtreeLeaves orders leaves by (leaf_role, leaf_id), the frozen rule, so
|
|
// two vaults that assembled the same facts in different orders agree.
|
|
func OrderSubtreeLeaves(leaves []SubtreeLeafInput) ([]string, error) {
|
|
ordered := append([]SubtreeLeafInput(nil), leaves...)
|
|
sort.SliceStable(ordered, func(left, right int) bool {
|
|
if ordered[left].LeafRole != ordered[right].LeafRole {
|
|
return ordered[left].LeafRole < ordered[right].LeafRole
|
|
}
|
|
return ordered[left].LeafID < ordered[right].LeafID
|
|
})
|
|
|
|
seen := make(map[string]struct{}, len(ordered))
|
|
digests := make([]string, 0, len(ordered))
|
|
for _, leaf := range ordered {
|
|
key := leaf.LeafRole + "\x00" + leaf.LeafID
|
|
if _, duplicate := seen[key]; duplicate {
|
|
return nil, fmt.Errorf("duplicate leaf id %s", leaf.LeafID)
|
|
}
|
|
seen[key] = struct{}{}
|
|
if err := assertProvenanceDigest("leaf.commitment", leaf.Commitment); err != nil {
|
|
return nil, err
|
|
}
|
|
digests = append(digests, leaf.Commitment)
|
|
}
|
|
return digests, nil
|
|
}
|
|
|
|
// TopLeafDigest builds a blinded top-tree leaf. The randomizer keeps the top
|
|
// tree from leaking which subtrees are empty or shared between capsules.
|
|
func TopLeafDigest(leafRole, commitment, randomizer string) (string, error) {
|
|
known := false
|
|
for _, role := range TopLeafRoles {
|
|
if role == leafRole {
|
|
known = true
|
|
break
|
|
}
|
|
}
|
|
if !known {
|
|
return "", fmt.Errorf("unknown top leaf role: %q", leafRole)
|
|
}
|
|
if err := assertProvenanceDigest("commitment", commitment); err != nil {
|
|
return "", err
|
|
}
|
|
if err := assertRandomizer(randomizer); err != nil {
|
|
return "", err
|
|
}
|
|
return DomainHashHex("attesto.provenance.v1.capsule_leaf", map[string]any{
|
|
"leaf_role": leafRole,
|
|
"commitment": commitment,
|
|
"randomizer": randomizer,
|
|
})
|
|
}
|
|
|
|
// OrderedTopLeafDigests builds the six typed top leaves, requiring each role
|
|
// exactly once.
|
|
//
|
|
// CapsuleRoot 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 it will accept. The check has to happen here, where the roles still
|
|
// exist, which is also why callers should reach for this rather than assembling
|
|
// the slice themselves.
|
|
func OrderedTopLeafDigests(commitments, randomizers map[string]string) ([]string, error) {
|
|
for _, role := range TopLeafRoles {
|
|
if _, ok := commitments[role]; !ok {
|
|
return nil, fmt.Errorf("capsule tree is missing top leaf %s", role)
|
|
}
|
|
}
|
|
known := make(map[string]struct{}, len(TopLeafRoles))
|
|
for _, role := range TopLeafRoles {
|
|
known[role] = struct{}{}
|
|
}
|
|
for role := range commitments {
|
|
if _, ok := known[role]; !ok {
|
|
return nil, fmt.Errorf("unknown top leaf role: %s", role)
|
|
}
|
|
}
|
|
digests := make([]string, 0, len(TopLeafRoles))
|
|
for _, role := range TopLeafRoles {
|
|
digest, err := TopLeafDigest(role, commitments[role], randomizers[role])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
digests = append(digests, digest)
|
|
}
|
|
return digests, nil
|
|
}
|
|
|
|
// CapsuleRoot folds the six typed top leaves into the capsule root.
|
|
func CapsuleRoot(orderedTopLeafDigests []string) (string, error) {
|
|
if len(orderedTopLeafDigests) != len(TopLeafRoles) {
|
|
return "", fmt.Errorf("capsule tree must carry exactly %d leaves", len(TopLeafRoles))
|
|
}
|
|
merkleRoot, err := provenanceFold("attesto.provenance.v1.capsule_node", orderedTopLeafDigests)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return DomainHashHex("attesto.provenance.v1.capsule_root", map[string]any{
|
|
"kind": "root",
|
|
"leaf_count": len(orderedTopLeafDigests),
|
|
"merkle_root": merkleRoot,
|
|
})
|
|
}
|
|
|
|
// ProvenanceProofStep is one sibling hop in an inclusion proof.
|
|
type ProvenanceProofStep struct {
|
|
Side string `json:"side"`
|
|
Sibling string `json:"sibling"`
|
|
}
|
|
|
|
// TwoHopProof is a complete disclosure: one subtree leaf, proven to the capsule
|
|
// root.
|
|
type TwoHopProof struct {
|
|
Subtree string `json:"subtree"`
|
|
Leaf string `json:"leaf"`
|
|
SubtreeSteps []ProvenanceProofStep `json:"subtree_steps"`
|
|
SubtreeLeafCount int `json:"subtree_leaf_count"`
|
|
SubtreeRoot string `json:"subtree_root"`
|
|
TopLeafRole string `json:"top_leaf_role"`
|
|
TopLeafRandomizer string `json:"top_leaf_randomizer"`
|
|
TopSteps []ProvenanceProofStep `json:"top_steps"`
|
|
CapsuleRoot string `json:"capsule_root"`
|
|
}
|
|
|
|
func replayProvenanceProof(domain, leaf string, steps []ProvenanceProofStep) (string, error) {
|
|
current := leaf
|
|
for _, step := range steps {
|
|
if err := assertProvenanceDigest("proof.sibling", step.Sibling); err != nil {
|
|
return "", err
|
|
}
|
|
var err error
|
|
switch step.Side {
|
|
case "right":
|
|
current, err = provenanceNode(domain, current, step.Sibling)
|
|
case "left":
|
|
current, err = provenanceNode(domain, step.Sibling, current)
|
|
default:
|
|
return "", fmt.Errorf("unknown proof side: %q", step.Side)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return current, nil
|
|
}
|
|
|
|
// VerifyTwoHop verifies a disclosure: leaf -> subtree root -> capsule root.
|
|
//
|
|
// It returns (false, nil) for a cryptographic failure and an error for a
|
|
// malformed object, so a caller can tell "this proof does not hold" from "this
|
|
// object is not a proof".
|
|
//
|
|
// The cross-tree attack this refuses: presenting a claim leaf against
|
|
// evidence_root. It fails on two independent grounds — the subtree folds under
|
|
// a different node domain, and the top leaf binds leaf_role.
|
|
func VerifyTwoHop(proof TwoHopProof) (bool, error) {
|
|
treeDomain, ok := subtreeTreeDomain[proof.Subtree]
|
|
if !ok {
|
|
return false, fmt.Errorf("unknown subtree: %q", proof.Subtree)
|
|
}
|
|
for field, digest := range map[string]string{
|
|
"leaf": proof.Leaf,
|
|
"subtree_root": proof.SubtreeRoot,
|
|
"capsule_root": proof.CapsuleRoot,
|
|
} {
|
|
if err := assertProvenanceDigest(field, digest); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if proof.TopLeafRole != subtreeTopRole[proof.Subtree] {
|
|
return false, nil
|
|
}
|
|
|
|
merkleRoot, err := replayProvenanceProof(treeDomain, proof.Leaf, proof.SubtreeSteps)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
derivedSubtreeRoot, err := SubtreeRoot(proof.Subtree, merkleRoot, proof.SubtreeLeafCount)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if derivedSubtreeRoot != proof.SubtreeRoot {
|
|
return false, nil
|
|
}
|
|
|
|
leaf, err := TopLeafDigest(proof.TopLeafRole, proof.SubtreeRoot, proof.TopLeafRandomizer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
topMerkle, err := replayProvenanceProof("attesto.provenance.v1.capsule_node", leaf, proof.TopSteps)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
derived, err := DomainHashHex("attesto.provenance.v1.capsule_root", map[string]any{
|
|
"kind": "root",
|
|
"leaf_count": len(TopLeafRoles),
|
|
"merkle_root": topMerkle,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(derived), []byte(proof.CapsuleRoot)) == 1, nil
|
|
}
|
|
|
|
// EnvelopeCoreCanonicalBytes returns the canonical bytes of the §8.3 envelope
|
|
// core: the egress envelope with signature and boundary removed, because both
|
|
// bind it and neither can be part of what they bind.
|
|
func EnvelopeCoreCanonicalBytes(envelope map[string]any) ([]byte, error) {
|
|
core := make(map[string]any, len(envelope))
|
|
for key, value := range envelope {
|
|
if key == "signature" || key == "boundary" {
|
|
continue
|
|
}
|
|
core[key] = value
|
|
}
|
|
if err := AssertCommitmentSafeNumbers(core, "$"); err != nil {
|
|
return nil, err
|
|
}
|
|
return CanonicalJSON(core)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- predicates
|
|
|
|
const (
|
|
ZKRangeProtocol = "ATTESTO-ZK-RANGE-001"
|
|
ZKRangeProtocolVersion = "0.1"
|
|
PredicateResultSchema = "attesto.provenance.predicate_result"
|
|
PredicateResultSchemaVersion = "0.1"
|
|
)
|
|
|
|
// RequiredNonClaims must appear in every predicate result. A result that dropped
|
|
// one would be read as the stronger statement, which is the failure this
|
|
// vocabulary prevents.
|
|
var RequiredNonClaims = [3]string{
|
|
"detector_correctness_not_proven",
|
|
"content_truth_not_proven",
|
|
"ai_generation_not_proven",
|
|
}
|
|
|
|
// forbiddenResultFields would let a consumer render a proven bound as a verdict
|
|
// about the content. A range proof says a named detector's measurement fell
|
|
// inside an interval and nothing more.
|
|
var forbiddenResultFields = map[string]struct{}{
|
|
"ai_generated": {}, "synthetic": {}, "is_fake": {}, "authentic": {},
|
|
"confidence": {}, "score": {}, "probability": {},
|
|
}
|
|
|
|
// PredicateReport separates what this client checked from what the issuer claims.
|
|
type PredicateReport struct {
|
|
Protocol string `json:"protocol"`
|
|
CapsuleRoot string `json:"capsule_root"`
|
|
ClaimID string `json:"claim_id"`
|
|
CommitmentC string `json:"commitment_c"`
|
|
Predicate map[string]any `json:"predicate"`
|
|
VerifiedHere map[string]string `json:"verified_here"`
|
|
ReportedByIssuer map[string]any `json:"reported_by_issuer"`
|
|
NotClaimed []map[string]any `json:"not_claimed"`
|
|
}
|
|
|
|
func rejectVerdictFields(node any, path string) error {
|
|
switch typed := node.(type) {
|
|
case map[string]any:
|
|
for key, value := range typed {
|
|
if _, bad := forbiddenResultFields[strings.ToLower(key)]; bad {
|
|
return fmt.Errorf("predicate result carries a verdict-shaped field at %s.%s", path, key)
|
|
}
|
|
if err := rejectVerdictFields(value, path+"."+key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case []any:
|
|
for index, value := range typed {
|
|
if err := rejectVerdictFields(value, fmt.Sprintf("%s[%d]", path, index)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InspectPredicateResult reports what this SDK established, kept apart from what
|
|
// the issuer claims.
|
|
//
|
|
// This is a verification client without ristretto255 arithmetic, so it cannot
|
|
// check a range proof. It says not_checked rather than passing the issuer's word
|
|
// through as though it had verified it: an SDK that reported the issuer's
|
|
// "verified" as its own is the failure this construction exists to prevent.
|
|
//
|
|
// capsuleInclusion is nil when the caller did not check inclusion.
|
|
func InspectPredicateResult(result map[string]any, capsuleInclusion *bool) (*PredicateReport, error) {
|
|
if result["schema"] != PredicateResultSchema || result["schema_version"] != PredicateResultSchemaVersion {
|
|
return nil, fmt.Errorf("unsupported predicate result schema")
|
|
}
|
|
if result["protocol"] != ZKRangeProtocol || result["protocol_version"] != ZKRangeProtocolVersion {
|
|
return nil, fmt.Errorf("unsupported predicate protocol")
|
|
}
|
|
|
|
rawClaims, _ := result["not_claimed"].([]any)
|
|
declared := map[string]struct{}{}
|
|
notClaimed := make([]map[string]any, 0, len(rawClaims))
|
|
for _, raw := range rawClaims {
|
|
claim, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("predicate result carries a malformed non-claim")
|
|
}
|
|
if id, ok := claim["id"].(string); ok {
|
|
declared[id] = struct{}{}
|
|
}
|
|
copied := map[string]any{}
|
|
for key, value := range claim {
|
|
copied[key] = value
|
|
}
|
|
notClaimed = append(notClaimed, copied)
|
|
}
|
|
for _, required := range RequiredNonClaims {
|
|
if _, ok := declared[required]; !ok {
|
|
return nil, fmt.Errorf("predicate result omits required non-claims: %s", required)
|
|
}
|
|
}
|
|
|
|
if err := rejectVerdictFields(result, "result"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bound := map[string]string{}
|
|
for _, field := range []string{"capsule_root", "claim_id", "commitment_c"} {
|
|
value, ok := result[field].(string)
|
|
if !ok || value == "" {
|
|
return nil, fmt.Errorf("predicate result has no %s to bind to", field)
|
|
}
|
|
bound[field] = value
|
|
}
|
|
|
|
inclusion := "not_checked"
|
|
if capsuleInclusion != nil {
|
|
if *capsuleInclusion {
|
|
inclusion = "verified"
|
|
} else {
|
|
inclusion = "failed"
|
|
}
|
|
}
|
|
|
|
predicate, _ := result["predicate"].(map[string]any)
|
|
issuer, _ := result["verification"].(map[string]any)
|
|
return &PredicateReport{
|
|
Protocol: ZKRangeProtocol,
|
|
CapsuleRoot: bound["capsule_root"],
|
|
ClaimID: bound["claim_id"],
|
|
CommitmentC: bound["commitment_c"],
|
|
Predicate: predicate,
|
|
// zk_predicate is always not_checked: verifying the proof needs curve
|
|
// arithmetic this client does not carry.
|
|
VerifiedHere: map[string]string{
|
|
"zk_predicate": "not_checked",
|
|
"capsule_inclusion": inclusion,
|
|
},
|
|
// What the issuer says it checked, kept separate so a reader can tell a
|
|
// claim from a check.
|
|
ReportedByIssuer: issuer,
|
|
NotClaimed: notClaimed,
|
|
}, nil
|
|
}
|
|
|
|
// ZKRangeWidths are the proof widths ATTESTO-ZK-RANGE-001 permits. Pinned rather
|
|
// than derived: a client offering a width outside this set would build
|
|
// statements the proving library refuses after the transcript is already bound.
|
|
var ZKRangeWidths = [4]uint{8, 16, 32, 64}
|
|
|
|
// zkRangeStatementFields is exactly what a range statement carries. Every field
|
|
// is folded into the proof transcript, so an extra one would bind to nothing and
|
|
// a missing one would change the challenges.
|
|
var zkRangeStatementFields = map[string]struct{}{
|
|
"capsule_root": {}, "claim_id": {}, "claim_descriptor_version": {},
|
|
"provider_id": {}, "provider_version": {}, "commitment_c": {},
|
|
"predicate_type": {}, "lower_bound": {}, "upper_bound": {},
|
|
"encoding_version": {}, "proof_scheme_version": {}, "verifier_nonce": {},
|
|
}
|
|
|
|
// ZKRangeWidth returns the smallest permitted width covering the whole interval.
|
|
//
|
|
// Both proved differences are bounded by upper-lower, so one width serves both.
|
|
// It is derived from the public bounds and never chosen by the prover: a prover
|
|
// who picked it could prove a wider range than the statement says.
|
|
func ZKRangeWidth(lowerBound, upperBound uint64) (uint, error) {
|
|
if upperBound < lowerBound {
|
|
return 0, fmt.Errorf("upper bound is below its lower bound")
|
|
}
|
|
span := upperBound - lowerBound
|
|
for _, width := range ZKRangeWidths {
|
|
if width == 64 || span < (uint64(1)<<width) {
|
|
return width, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("interval is wider than the largest permitted proof")
|
|
}
|
|
|
|
// encodedBound reads a JSON number as the encoded integer it must be. A float
|
|
// bound is refused rather than truncated: the encoding registry exists so no
|
|
// rounding step is left for three SDKs to disagree about.
|
|
func encodedBound(value any, name string) (uint64, error) {
|
|
number, ok := value.(float64)
|
|
if !ok {
|
|
return 0, fmt.Errorf("%s must be an encoded integer, never a float", name)
|
|
}
|
|
if number < 0 || number != float64(uint64(number)) {
|
|
return 0, fmt.Errorf("%s must be an encoded integer, never a float", name)
|
|
}
|
|
return uint64(number), nil
|
|
}
|
|
|
|
// ValidateRangeStatement checks a statement is well-formed and returns the width
|
|
// its bounds imply.
|
|
//
|
|
// This does not verify the proof — that needs curve arithmetic no SDK carries.
|
|
// It refuses the statements no honest prover produced, which is a check a
|
|
// verification client can make on its own.
|
|
func ValidateRangeStatement(statement map[string]any) (uint, error) {
|
|
for field := range zkRangeStatementFields {
|
|
if _, ok := statement[field]; !ok {
|
|
return 0, fmt.Errorf("range statement is missing %s", field)
|
|
}
|
|
}
|
|
for field := range statement {
|
|
if _, ok := zkRangeStatementFields[field]; !ok {
|
|
return 0, fmt.Errorf("range statement carries an unexpected field: %s", field)
|
|
}
|
|
}
|
|
if statement["predicate_type"] != "inclusive_range" {
|
|
return 0, fmt.Errorf("statement predicate is not the v1 inclusive range")
|
|
}
|
|
for _, field := range []string{
|
|
"capsule_root", "claim_id", "claim_descriptor_version", "provider_id",
|
|
"provider_version", "commitment_c", "encoding_version",
|
|
"proof_scheme_version", "verifier_nonce",
|
|
} {
|
|
value, ok := statement[field].(string)
|
|
if !ok || value == "" {
|
|
return 0, fmt.Errorf("range statement %s is empty or not a string", field)
|
|
}
|
|
}
|
|
lower, err := encodedBound(statement["lower_bound"], "lower_bound")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
upper, err := encodedBound(statement["upper_bound"], "upper_bound")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return ZKRangeWidth(lower, upper)
|
|
}
|
|
|
|
// ------------------------------------------------- disclosure verification
|
|
|
|
const (
|
|
DisclosureProtocol = "ATTESTO-DISCLOSURE-001"
|
|
DisclosureProtocolVersion = "0.1"
|
|
disclosureDomain = "attesto.provenance.v1.disclosure"
|
|
)
|
|
|
|
// subtreeLeafDomain is the domain a leaf's own commitment was made under, as
|
|
// opposed to the domain its subtree folds in. Verifying a disclosure needs both:
|
|
// one opens the leaf, the other proves it belongs to the tree.
|
|
var subtreeLeafDomain = map[string]string{
|
|
"claims": "attesto.provenance.v1.claim",
|
|
"evidence": "attesto.provenance.v1.evidence",
|
|
"policy_results": "attesto.provenance.v1.policy_result",
|
|
}
|
|
|
|
// DisclosureNotClaimed is the seventh non-claim, on top of TM-05's six. A
|
|
// verifier that reported only what it checked would leave a reader to assume the
|
|
// picture is complete.
|
|
var DisclosureNotClaimed = map[string]string{
|
|
"id": "undisclosed_facts_absent",
|
|
"statement": "This disclosure proves the revealed leaves are in the capsule. " +
|
|
"It is not a statement that the capsule holds nothing else.",
|
|
}
|
|
|
|
// VerifiedLeaf is one leaf a presentation proved.
|
|
type VerifiedLeaf struct {
|
|
Subtree string `json:"subtree"`
|
|
LeafRole string `json:"leaf_role"`
|
|
Value any `json:"value"`
|
|
}
|
|
|
|
// DisclosureReport is what a presentation established, and what it did not.
|
|
//
|
|
// Ok is true only when every revealed leaf opened its commitment and every proof
|
|
// folded to the presented capsule root. Freshness and SubjectChecked are reported
|
|
// separately rather than folded in: a caller that issued no challenge got weaker
|
|
// evidence than one that did, and saying so is what separates a verifier from a
|
|
// rubber stamp.
|
|
type DisclosureReport struct {
|
|
Ok bool
|
|
CapsuleRoot string
|
|
VerifiedLeaves []VerifiedLeaf
|
|
Freshness string
|
|
SubjectChecked bool
|
|
Problems []string
|
|
NotClaimed []map[string]string
|
|
}
|
|
|
|
type disclosureOptions struct {
|
|
expectedNonce *string
|
|
subjectCommitment *string
|
|
now *time.Time
|
|
}
|
|
|
|
// DisclosureOption configures a verification.
|
|
type DisclosureOption func(*disclosureOptions)
|
|
|
|
// WithExpectedNonce turns on interactive mode: supply the challenge issued to
|
|
// the holder. Without it the presentation is only bounded by its expiry, which
|
|
// is weaker evidence, and the report says so.
|
|
func WithExpectedNonce(nonce string) DisclosureOption {
|
|
return func(o *disclosureOptions) { o.expectedNonce = &nonce }
|
|
}
|
|
|
|
// WithSubjectCommitment checks the presentation is bound to the asset held.
|
|
func WithSubjectCommitment(commitment string) DisclosureOption {
|
|
return func(o *disclosureOptions) { o.subjectCommitment = &commitment }
|
|
}
|
|
|
|
// WithVerificationTime overrides the clock, for testing expiry without waiting.
|
|
func WithVerificationTime(at time.Time) DisclosureOption {
|
|
return func(o *disclosureOptions) { o.now = &at }
|
|
}
|
|
|
|
func disclosureSigningPayload(presentation map[string]any) map[string]any {
|
|
payload := make(map[string]any, len(presentation))
|
|
for key, value := range presentation {
|
|
if key != "signature" {
|
|
payload[key] = value
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// VerifyDisclosure verifies a selective disclosure offline.
|
|
//
|
|
// It needs no network and no platform: the presentation carries its own
|
|
// signature, the revealed values with their randomizers, and two-hop proofs to
|
|
// the capsule root.
|
|
//
|
|
// Every problem is collected rather than returned on the first one, so a caller
|
|
// sees all of what is wrong with a presentation instead of only the earliest.
|
|
func VerifyDisclosure(presentation map[string]any, options ...DisclosureOption) DisclosureReport {
|
|
opts := &disclosureOptions{}
|
|
for _, option := range options {
|
|
option(opts)
|
|
}
|
|
|
|
notClaimed := []map[string]string{DisclosureNotClaimed}
|
|
if presentation["protocol"] != DisclosureProtocol ||
|
|
presentation["protocol_version"] != DisclosureProtocolVersion {
|
|
return DisclosureReport{
|
|
Freshness: "unknown",
|
|
Problems: []string{"unsupported disclosure protocol"},
|
|
NotClaimed: notClaimed,
|
|
}
|
|
}
|
|
|
|
problems := []string{}
|
|
moment := time.Now().UTC()
|
|
if opts.now != nil {
|
|
moment = *opts.now
|
|
}
|
|
if raw, ok := presentation["expires_at"].(string); ok {
|
|
if expires, err := time.Parse(time.RFC3339Nano, raw); err != nil {
|
|
problems = append(problems, "expiry is malformed")
|
|
} else if !moment.Before(expires) {
|
|
problems = append(problems, "disclosure has expired")
|
|
}
|
|
} else {
|
|
problems = append(problems, "expiry is malformed")
|
|
}
|
|
|
|
if opts.expectedNonce != nil && presentation["nonce"] != *opts.expectedNonce {
|
|
problems = append(problems, "nonce is not the one issued")
|
|
}
|
|
|
|
problems = append(problems, verifyDisclosureSignature(presentation)...)
|
|
|
|
revealed := map[string]map[string]any{}
|
|
for _, raw := range asSlice(presentation["revealed"]) {
|
|
if entry, ok := raw.(map[string]any); ok {
|
|
revealed[toString(entry["leaf_id"])] = entry
|
|
}
|
|
}
|
|
proofs := map[string]map[string]any{}
|
|
for _, raw := range asSlice(presentation["inclusion_proofs"]) {
|
|
if proof, ok := raw.(map[string]any); ok {
|
|
proofs[toString(proof["leaf_id"])] = proof
|
|
}
|
|
}
|
|
if len(revealed) == 0 || len(revealed) != len(proofs) {
|
|
problems = append(problems, "every revealed leaf needs its proof and every proof its leaf")
|
|
}
|
|
|
|
capsuleRoot := toString(presentation["capsule_root"])
|
|
verified := []VerifiedLeaf{}
|
|
for leafID, entry := range revealed {
|
|
proof, ok := proofs[leafID]
|
|
if !ok {
|
|
problems = append(problems, "every revealed leaf needs its proof and every proof its leaf")
|
|
continue
|
|
}
|
|
subtree := toString(entry["subtree"])
|
|
if toString(proof["capsule_root"]) != capsuleRoot || toString(proof["subtree"]) != subtree {
|
|
problems = append(problems, "proof does not match its revealed leaf: "+leafID)
|
|
continue
|
|
}
|
|
domain, known := subtreeLeafDomain[subtree]
|
|
if !known {
|
|
problems = append(problems, "unknown subtree: "+subtree)
|
|
continue
|
|
}
|
|
opened, err := VerifyProvenanceCommitment(
|
|
domain, entry["value"], toString(entry["randomizer"]), toString(proof["leaf"]),
|
|
)
|
|
if err != nil || !opened {
|
|
problems = append(problems, "revealed value does not open its leaf commitment: "+leafID)
|
|
continue
|
|
}
|
|
included, err := verifyTwoHopFromMap(proof)
|
|
if err != nil || !included {
|
|
problems = append(problems, "leaf is not included under the presented capsule root: "+leafID)
|
|
continue
|
|
}
|
|
leafValue := entry["value"]
|
|
if wrapped, ok := leafValue.(map[string]any); ok {
|
|
leafValue = wrapped["value"]
|
|
}
|
|
verified = append(verified, VerifiedLeaf{
|
|
Subtree: subtree, LeafRole: toString(entry["leaf_role"]), Value: leafValue,
|
|
})
|
|
}
|
|
|
|
subjectChecked := false
|
|
if opts.subjectCommitment != nil {
|
|
if toString(presentation["subject_binding"]) != *opts.subjectCommitment {
|
|
problems = append(problems, "disclosure is bound to a different asset")
|
|
} else {
|
|
subjectChecked = true
|
|
}
|
|
}
|
|
|
|
freshness := "bounded_lifetime"
|
|
if opts.expectedNonce != nil {
|
|
freshness = "challenge"
|
|
}
|
|
return DisclosureReport{
|
|
Ok: len(problems) == 0,
|
|
CapsuleRoot: capsuleRoot,
|
|
VerifiedLeaves: verified,
|
|
Freshness: freshness,
|
|
SubjectChecked: subjectChecked,
|
|
Problems: problems,
|
|
NotClaimed: notClaimed,
|
|
}
|
|
}
|
|
|
|
func verifyDisclosureSignature(presentation map[string]any) []string {
|
|
signature, _ := presentation["signature"].(map[string]any)
|
|
issuer, _ := presentation["issuer"].(map[string]any)
|
|
if signature == nil || issuer == nil ||
|
|
toString(signature["algorithm"]) != "ed25519" ||
|
|
toString(signature["domain"]) != disclosureDomain {
|
|
return []string{"signature is not a v1 disclosure signature"}
|
|
}
|
|
publicKey, err := hex.DecodeString(toString(issuer["public_key"]))
|
|
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
|
return []string{"signature did not verify"}
|
|
}
|
|
sig, err := hex.DecodeString(toString(signature["value"]))
|
|
if err != nil || len(sig) != ed25519.SignatureSize {
|
|
return []string{"signature did not verify"}
|
|
}
|
|
payload, err := CanonicalJSON(disclosureSigningPayload(presentation))
|
|
if err != nil {
|
|
return []string{"signature did not verify"}
|
|
}
|
|
message := append([]byte(disclosureDomain), 0)
|
|
message = append(message, payload...)
|
|
if !ed25519.Verify(publicKey, message, sig) {
|
|
return []string{"signature did not verify"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifyTwoHopFromMap(proof map[string]any) (bool, error) {
|
|
scrubbed := make(map[string]any, len(proof))
|
|
for key, value := range proof {
|
|
if key != "leaf_id" {
|
|
scrubbed[key] = value
|
|
}
|
|
}
|
|
encoded, err := json.Marshal(scrubbed)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var typed TwoHopProof
|
|
if err := json.Unmarshal(encoded, &typed); err != nil {
|
|
return false, err
|
|
}
|
|
return VerifyTwoHop(typed)
|
|
}
|
|
|
|
func asSlice(value any) []any {
|
|
if typed, ok := value.([]any); ok {
|
|
return typed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toString(value any) string {
|
|
if typed, ok := value.(string); ok {
|
|
return typed
|
|
}
|
|
return ""
|
|
}
|