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:
+273
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user