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>
This commit is contained in:
Codex
2026-08-22 12:52:22 +02:00
co-authored by Claude Opus 5
parent 0b881e1a74
commit 7b34da7c78
2 changed files with 491 additions and 0 deletions
+273
View File
@@ -14,11 +14,14 @@ package 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 (
@@ -661,3 +664,273 @@ func ValidateRangeStatement(statement map[string]any) (uint, error) {
}
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 ""
}