feat(attesto3): make every SDK check every vector it is able to check
Twelve of twenty-five provenance vectors were consumed by no SDK. From outside the repository that looked exactly like full coverage, which is the problem: a corpus proves nothing about an implementation that never loads it. Vectors now declare what they require. `sha256` vectors use SHA-256 and canonical JSON, which all three SDKs have, so an unconsumed one is a gap and fails a contract. `ristretto255` vectors need curve scalar arithmetic no SDK carries; those are a declared boundary with a stated reason rather than a silent skip, so the exemption cannot spread by habit. Coverage went from 13/17 reachable in each SDK to 17/17. Closing the four gaps surfaced a real verifier weakness: `capsule_root` 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 all three SDKs accepted. Each gains `ordered_top_leaf_digests`, which requires each of the six roles exactly once, and the safe path is now the easy one. Two findings of my own drift: * The Go corpus-typing test accepted only `valid` and `invalid`, so it had been failing since the Sprint 1 recovery added vectors carrying `differs` and `rejected`. I updated Python's typing test then and not Go's, and no gate caught it because the SDK parity suites are not in the sprint gates. Fixed, and both Go and TypeScript now also require the capability declaration. * TypeScript's strict indexing caught that a missing randomizer would have reached the hash as the string "undefined". Both halves are now checked. Python 88, Go ok, TypeScript 107, Local Vault 375, edge 116, backend 1404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -263,6 +263,40 @@ func TopLeafDigest(leafRole, commitment, randomizer string) (string, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// CapsuleRoot folds the six typed top leaves into the capsule root.
|
||||||
func CapsuleRoot(orderedTopLeafDigests []string) (string, error) {
|
func CapsuleRoot(orderedTopLeafDigests []string) (string, error) {
|
||||||
if len(orderedTopLeafDigests) != len(TopLeafRoles) {
|
if len(orderedTopLeafDigests) != len(TopLeafRoles) {
|
||||||
|
|||||||
+169
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,13 +56,20 @@ func TestProvenanceCorpusIsPresentAndTyped(t *testing.T) {
|
|||||||
if vector["protocol"] != ProvenanceProtocol {
|
if vector["protocol"] != ProvenanceProtocol {
|
||||||
t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"])
|
t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"])
|
||||||
}
|
}
|
||||||
|
// Four outcomes, deliberately distinct. "invalid" means the check ran
|
||||||
|
// and answered no; "rejected" means the input was refused before any
|
||||||
|
// check could run; "differs" is not a validity claim but a requirement
|
||||||
|
// that two values not be equal.
|
||||||
switch vector["expectation"] {
|
switch vector["expectation"] {
|
||||||
case "valid":
|
case "valid", "differs":
|
||||||
case "invalid":
|
case "invalid", "rejected":
|
||||||
invalid++
|
invalid++
|
||||||
default:
|
default:
|
||||||
t.Fatalf("%s: bad expectation %v", entry, vector["expectation"])
|
t.Fatalf("%s: bad expectation %v", entry, vector["expectation"])
|
||||||
}
|
}
|
||||||
|
if vector["requires"] == nil {
|
||||||
|
t.Fatalf("%s: does not declare what it requires", entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if invalid < 5 {
|
if invalid < 5 {
|
||||||
t.Fatalf("corpus carries only %d negative vectors", invalid)
|
t.Fatalf("corpus carries only %d negative vectors", invalid)
|
||||||
@@ -393,3 +401,162 @@ func TestProvenanceCanonicalJSONMatchesRust(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------- canonical claim descriptor
|
||||||
|
|
||||||
|
func TestProvenanceCanonicalClaimDescriptorReproducesRustLeaf(t *testing.T) {
|
||||||
|
// A claim leaf commits subject, authority and evidence refs, not just a
|
||||||
|
// value. REVIEW-02 §7.2 widened the descriptor precisely so a disclosure
|
||||||
|
// cannot present semantics the leaf never committed; building the older
|
||||||
|
// shape here would break every cross-language disclosure.
|
||||||
|
vector := loadProvenanceVector(t, "provenance-canonical-claim-descriptor-valid")
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string),
|
||||||
|
vector["descriptor"],
|
||||||
|
vector["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
if digest != vector["expected_digest"].(string) {
|
||||||
|
t.Fatalf("claim leaf mismatch:\n got %s\nwant %s", digest, vector["expected_digest"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceClaimBackingChangesTheLeaf(t *testing.T) {
|
||||||
|
// Two claims that read the same but rest on different backing must not share
|
||||||
|
// a leaf: sharing one would let a claim attested by one provider be presented
|
||||||
|
// as attested by another, which no later check catches.
|
||||||
|
for _, name := range []string{
|
||||||
|
"provenance-canonical-claim-descriptor-authority-mutation",
|
||||||
|
"provenance-canonical-claim-descriptor-evidence-ref-mutation",
|
||||||
|
} {
|
||||||
|
vector := loadProvenanceVector(t, name)
|
||||||
|
digest, err := ProvenanceCommitmentDigest(
|
||||||
|
vector["domain"].(string),
|
||||||
|
vector["descriptor"],
|
||||||
|
vector["randomizer"].(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: commit: %v", name, err)
|
||||||
|
}
|
||||||
|
if digest != vector["expected_digest"].(string) {
|
||||||
|
t.Fatalf("%s: digest mismatch:\n got %s\nwant %s", name, digest, vector["expected_digest"])
|
||||||
|
}
|
||||||
|
if digest == vector["must_differ_from"].(string) {
|
||||||
|
t.Fatalf("%s: leaf collided with the unmutated claim", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ malformed capsule trees
|
||||||
|
|
||||||
|
func TestProvenanceMalformedCapsuleTreeIsRefused(t *testing.T) {
|
||||||
|
// The duplicate-role case is the one that needs OrderedTopLeafDigests:
|
||||||
|
// CapsuleRoot receives digests, and by then the role is gone, so a tree
|
||||||
|
// carrying one role twice and another not at all folds to a root it accepts.
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-invalid-leaf")
|
||||||
|
valid := loadProvenanceVector(t, "provenance-capsule-root-valid")
|
||||||
|
|
||||||
|
digest := strings.Repeat("aa", 32)
|
||||||
|
randomizer := strings.Repeat("11", 32)
|
||||||
|
commitments := map[string]string{}
|
||||||
|
randomizers := map[string]string{}
|
||||||
|
for _, role := range TopLeafRoles {
|
||||||
|
commitments[role] = digest
|
||||||
|
randomizers[role] = randomizer
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons := map[string]struct{}{}
|
||||||
|
cases := vector["cases"].([]any)
|
||||||
|
for _, raw := range cases {
|
||||||
|
c := raw.(map[string]any)
|
||||||
|
switch {
|
||||||
|
case c["omit_role"] != nil:
|
||||||
|
broken := map[string]string{}
|
||||||
|
for role, value := range commitments {
|
||||||
|
if role != c["omit_role"].(string) {
|
||||||
|
broken[role] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil {
|
||||||
|
t.Fatalf("a tree missing %v was accepted", c["omit_role"])
|
||||||
|
}
|
||||||
|
case c["duplicate_role"] != nil:
|
||||||
|
role := c["duplicate_role"].(string)
|
||||||
|
dropped := ""
|
||||||
|
for _, candidate := range TopLeafRoles {
|
||||||
|
if candidate != role {
|
||||||
|
dropped = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broken := map[string]string{}
|
||||||
|
for r, value := range commitments {
|
||||||
|
if r != dropped {
|
||||||
|
broken[r] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broken[role+"_again"] = digest
|
||||||
|
if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil {
|
||||||
|
t.Fatalf("a tree carrying %s twice was accepted", role)
|
||||||
|
}
|
||||||
|
case c["malformed_commitment"] != nil:
|
||||||
|
if _, err := TopLeafDigest("claims_root", c["malformed_commitment"].(string), randomizer); err == nil {
|
||||||
|
t.Fatal("a malformed commitment was accepted")
|
||||||
|
}
|
||||||
|
case c["empty_subtree"] != nil:
|
||||||
|
if _, err := SubtreeMerkleRoot(c["empty_subtree"].(string), nil); err == nil {
|
||||||
|
t.Fatalf("an empty %v tree was accepted", c["empty_subtree"])
|
||||||
|
}
|
||||||
|
case c["duplicate_leaf_id"] != nil:
|
||||||
|
leaves := leafInputs(t, valid["claims"])
|
||||||
|
repeated := leaves[0]
|
||||||
|
repeated.LeafID = c["duplicate_leaf_id"].(string)
|
||||||
|
if _, err := OrderSubtreeLeaves([]SubtreeLeafInput{repeated, repeated}); err == nil {
|
||||||
|
t.Fatal("a duplicate leaf id was accepted")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unhandled refusal case: %v", c)
|
||||||
|
}
|
||||||
|
reasons[c["expected_error"].(string)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reasons) != len(cases) {
|
||||||
|
t.Fatalf("each case must fail for its own reason: %d reasons for %d cases", len(reasons), len(cases))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvenanceSafeAssemblyAcceptsAWellFormedTree(t *testing.T) {
|
||||||
|
// The refusals above must not be a function that refuses everything.
|
||||||
|
vector := loadProvenanceVector(t, "provenance-capsule-root-valid")
|
||||||
|
claimsRoot, _ := buildSubtree(t, "claims", vector["claims"])
|
||||||
|
evidenceRoot, _ := buildSubtree(t, "evidence", vector["evidence"])
|
||||||
|
policyRoot, _ := buildSubtree(t, "policy_results", vector["policy_results"])
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
randomizers := map[string]string{}
|
||||||
|
for role, value := range vector["top_randomizers"].(map[string]any) {
|
||||||
|
randomizers[role] = value.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
digests, err := OrderedTopLeafDigests(commitments, randomizers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assemble: %v", err)
|
||||||
|
}
|
||||||
|
root, err := CapsuleRoot(digests)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("capsule root: %v", err)
|
||||||
|
}
|
||||||
|
expected := vector["expected"].(map[string]any)
|
||||||
|
if root != expected["capsule_root"].(string) {
|
||||||
|
t.Fatalf("capsule root mismatch:\n got %s\nwant %s", root, expected["capsule_root"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user