diff --git a/provenance.go b/provenance.go new file mode 100644 index 0000000..958abea --- /dev/null +++ b/provenance.go @@ -0,0 +1,395 @@ +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/subtle" + "encoding/hex" + "fmt" + "sort" +) + +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": {}, +} + +// 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, + }) +} + +// 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) +} diff --git a/provenance_parity_test.go b/provenance_parity_test.go new file mode 100644 index 0000000..dac2fff --- /dev/null +++ b/provenance_parity_test.go @@ -0,0 +1,388 @@ +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" + "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"]) + } + switch vector["expectation"] { + case "valid": + case "invalid": + invalid++ + default: + t.Fatalf("%s: bad expectation %v", entry, vector["expectation"]) + } + } + 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) + if len(domains) != len(ProvenanceDomains) || len(domains) != 18 { + t.Fatalf("registry size mismatch: rust=%d go=%d", len(domains), len(ProvenanceDomains)) + } + 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"]) + } + } +}