Files
attesto-go/provenance.go
T
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

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 ""
}